From 7f0996d9869cbc378f5c0dd9e3de20801338afcf Mon Sep 17 00:00:00 2001
From: Daniel Lockyer <hi@daniellockyer.com>
Date: Tue, 23 Aug 2022 17:37:09 +0200
Subject: [PATCH] Implemented resource linking in Audit Log

refs https://github.com/TryGhost/Toolbox/issues/356

- we have a very crude version of this before but it just wasn't
  maintainable
- one of the first things I did here was to add `include=resource` on
  the API call, so it returns the fields we need without extra API
  requests
- after we have the id/slug, I could build a route and model array
  dynamically, or return null if we can't redirect to the object (it
  doesn't exist)
---
 .../components/settings/audit-log/table.hbs   | 10 +--
 .../app/helpers/audit-log-event-fetcher.js    |  5 +-
 .../app/helpers/parse-audit-log-event.js      | 62 ++++++++++++++++---
 .../utils/serializers/output/utils/clean.js   | 10 +--
 ghost/core/test/e2e-api/admin/utils.js        |  2 +-
 5 files changed, 68 insertions(+), 21 deletions(-)

diff --git a/ghost/admin/app/components/settings/audit-log/table.hbs b/ghost/admin/app/components/settings/audit-log/table.hbs
index dfa999b798..f2fc61a8b2 100644
--- a/ghost/admin/app/components/settings/audit-log/table.hbs
+++ b/ghost/admin/app/components/settings/audit-log/table.hbs
@@ -36,14 +36,14 @@
                             <strong>{{capitalize-first-letter ev.contextResource.first}}</strong>
                             <code>({{ev.contextResource.second}})</code>
                         </span>
-                    {{else if (or ev.resource.title ev.resource.name ev.original.context.primary_name)}}
-                        {{#if (and (or ev.resource.title ev.resource.name) ev.linkable)}}
-                            <LinkTo @route="editor.edit" @models={{array ev.resource.displayName ev.resource.id}} class="permalink">
-                                <strong>{{or ev.resource.title ev.resource.name}}</strong>
+                    {{else if (or ev.original.resource.title ev.original.resource.name ev.original.context.primary_name)}}
+                        {{#if ev.linkTarget}}
+                            <LinkTo @route={{ev.linkTarget.route}} @models={{ev.linkTarget.models}} class="permalink">
+                                <strong>{{or ev.original.resource.title ev.original.resource.name}}</strong>
                             </LinkTo>
                         {{else}}
                             <span class="midgrey">
-                                <strong>{{or ev.resource.title ev.resource.name ev.original.context.primary_name}}</strong>
+                                <strong>{{or ev.original.resource.title ev.original.resource.name ev.original.context.primary_name}}</strong>
                             </span>
                         {{/if}}
                     {{else}}
diff --git a/ghost/admin/app/helpers/audit-log-event-fetcher.js b/ghost/admin/app/helpers/audit-log-event-fetcher.js
index 95b76d164f..5db8845f56 100644
--- a/ghost/admin/app/helpers/audit-log-event-fetcher.js
+++ b/ghost/admin/app/helpers/audit-log-event-fetcher.js
@@ -75,7 +75,10 @@ export default class AuditLogEventFetcher extends Resource {
             this.isLoading = true;
 
             const url = this.ghostPaths.url.api('actions');
-            const data = Object.assign({}, queryParams, {limit: this.args.named.pageSize});
+            const data = Object.assign({}, queryParams, {
+                include: 'resource',
+                limit: this.args.named.pageSize
+            });
             const {actions} = yield this.ajax.request(url, {data});
 
             if (actions.length < data.limit) {
diff --git a/ghost/admin/app/helpers/parse-audit-log-event.js b/ghost/admin/app/helpers/parse-audit-log-event.js
index 0aaaecb5be..73538234ea 100644
--- a/ghost/admin/app/helpers/parse-audit-log-event.js
+++ b/ghost/admin/app/helpers/parse-audit-log-event.js
@@ -8,20 +8,15 @@ export default class ParseAuditLogEvent extends Helper {
         const action = getAction(ev);
         const actionIcon = getActionIcon(ev);
         const getActor = () => this.store.findRecord(ev.actor_type, ev.actor_id, {reload: false});
-        const getResource = () => this.store.findRecord(ev.resource_type, ev.resource_id, {reload: false});
         const contextResource = getContextResource(ev);
-
-        const linkable = ['page', 'post'].includes(ev.resource_type) && ev.event !== 'deleted';
+        const linkTarget = getLinkTarget(ev);
 
         return {
             get actor() {
                 return getActor();
             },
-            get resource() {
-                return getResource();
-            },
             contextResource,
-            linkable,
+            linkTarget,
             actionIcon,
             action,
             original: ev
@@ -29,6 +24,51 @@ export default class ParseAuditLogEvent extends Helper {
     }
 }
 
+function getLinkTarget(ev) {
+    let resourceType = ev.resource_type;
+
+    if (ev.event !== 'deleted') {
+        switch (ev.resource_type) {
+        case 'page':
+        case 'post':
+            if (!ev.resource.id) {
+                return null;
+            }
+
+            if (resourceType === 'post') {
+                if (ev.context?.type) {
+                    resourceType = ev.context?.type;
+                }
+            }
+
+            return {
+                route: 'editor.edit',
+                models: [resourceType, ev.resource.id]
+            };
+        case 'tag':
+            if (!ev.resource.slug) {
+                return null;
+            }
+
+            return {
+                route: 'tag',
+                models: [ev.resource.slug]
+            };
+        case 'user':
+            if (!ev.resource.slug) {
+                return null;
+            }
+
+            return {
+                route: 'settings.staff.user',
+                models: [ev.resource.slug]
+            };
+        }
+    }
+
+    return null;
+}
+
 function getActionIcon(ev) {
     switch (ev.event) {
     case 'added':
@@ -51,6 +91,14 @@ function getAction(ev) {
         resourceType = 'settings';
     }
 
+    // Because a `page` and `post` both use the same model, we store the
+    // actual type in the context, so let's check if that exists
+    if (resourceType === 'post') {
+        if (ev.context?.type) {
+            resourceType = ev.context?.type;
+        }
+    }
+
     return `${ev.event} ${resourceType}`;
 }
 
diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js
index b230305747..a3c7e484d5 100644
--- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js
+++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/clean.js
@@ -130,9 +130,6 @@ const post = (attrs, frame) => {
 
 const action = (attrs) => {
     if (attrs.actor) {
-        delete attrs.actor_id;
-        delete attrs.resource_id;
-
         if (attrs.actor_type === 'user') {
             attrs.actor = _.pick(attrs.actor, ['id', 'name', 'slug', 'profile_image']);
             attrs.actor.image = attrs.actor.profile_image;
@@ -142,12 +139,11 @@ const action = (attrs) => {
             attrs.actor.image = attrs.actor.icon_image;
             delete attrs.actor.icon_image;
         }
-    } else if (attrs.resource) {
-        delete attrs.actor_id;
-        delete attrs.resource_id;
+    }
 
+    if (attrs.resource) {
         // @NOTE: we only support posts right now
-        attrs.resource = _.pick(attrs.resource, ['id', 'title', 'slug', 'feature_image']);
+        attrs.resource = _.pick(attrs.resource, ['id', 'title', 'slug', 'feature_image', 'name']);
         attrs.resource.image = attrs.resource.feature_image;
         delete attrs.resource.feature_image;
     }
diff --git a/ghost/core/test/e2e-api/admin/utils.js b/ghost/core/test/e2e-api/admin/utils.js
index 0b595cf4e1..d1781c10dd 100644
--- a/ghost/core/test/e2e-api/admin/utils.js
+++ b/ghost/core/test/e2e-api/admin/utils.js
@@ -26,7 +26,7 @@ const expectedProperties = {
     members: ['members', 'meta'],
     snippets: ['snippets', 'meta'],
 
-    action: ['id', 'resource_type', 'actor_type', 'event', 'created_at', 'actor', 'context'],
+    action: ['id', 'resource_type', 'actor_type', 'event', 'created_at', 'actor', 'context', 'resource_id', 'actor_id'],
 
     config: [
         'version',