diff --git a/ghost/admin/app/components/gh-editor-post-status.hbs b/ghost/admin/app/components/gh-editor-post-status.hbs index da3d7ec8f37..b963c7a75fc 100644 --- a/ghost/admin/app/components/gh-editor-post-status.hbs +++ b/ghost/admin/app/components/gh-editor-post-status.hbs @@ -66,4 +66,6 @@ Draft {{unless @hasDirtyAttributes "- Saved"}} {{/if}} + + diff --git a/ghost/admin/app/components/gh-member-avatar.hbs b/ghost/admin/app/components/gh-member-avatar.hbs index 0d8dd3aff4f..b48bf49c14a 100644 --- a/ghost/admin/app/components/gh-member-avatar.hbs +++ b/ghost/admin/app/components/gh-member-avatar.hbs @@ -1,5 +1,5 @@
- {{#if @member.email}} + {{#if (or @member.email @member.name @member.avatarImage @member.avatar_image @name)}}
{{this.initials}}
diff --git a/ghost/admin/app/components/gh-post-editing-indicator.hbs b/ghost/admin/app/components/gh-post-editing-indicator.hbs new file mode 100644 index 00000000000..b5ef23453eb --- /dev/null +++ b/ghost/admin/app/components/gh-post-editing-indicator.hbs @@ -0,0 +1,22 @@ +{{#if this.activeEditor}} + + {{#if this.isListVariant}} + {{svg-jar "pen"}} + {{this.indicatorText}} + {{else}} + {{#if this.indicatorPrefix}} + {{this.indicatorPrefix}} + {{/if}} + + {{this.indicatorText}} + {{/if}} + +{{/if}} diff --git a/ghost/admin/app/components/gh-post-editing-indicator.js b/ghost/admin/app/components/gh-post-editing-indicator.js new file mode 100644 index 00000000000..c4e0e69b008 --- /dev/null +++ b/ghost/admin/app/components/gh-post-editing-indicator.js @@ -0,0 +1,76 @@ +import Component from '@glimmer/component'; +import moment from 'moment-timezone'; +import {get} from '@ember/object'; +import {inject as service} from '@ember/service'; + +const ACTIVE_EDITING_TTL_SECONDS = 120; + +export default class GhPostEditingIndicatorComponent extends Component { + @service clock; + @service session; + + get activeEditor() { + // force a recompute while the lease is ticking down + get(this.clock, 'second'); + + const heartbeatAt = this.args.post?.editingHeartbeatAt; + const editingBy = this.args.post?.editingBy; + const editingName = this.args.post?.editingName; + + if (!heartbeatAt || !editingBy || editingBy === this.session.user.id) { + return null; + } + + if (moment.utc().diff(moment.utc(heartbeatAt), 'seconds') >= ACTIVE_EDITING_TTL_SECONDS) { + return null; + } + + return { + name: editingName || 'Another staff user', + heartbeatAt: moment.utc(heartbeatAt).toISOString() + }; + } + + get isListVariant() { + return this.args.variant === 'list'; + } + + get indicatorClass() { + return `gh-post-editing-indicator gh-post-editing-indicator--${this.isListVariant ? 'list' : 'editor'}`; + } + + get indicatorText() { + if (!this.activeEditor) { + return null; + } + + if (this.isListVariant) { + return `Being edited by ${this.activeEditor.name}`; + } + + return this.activeEditor.name; + } + + get indicatorPrefix() { + if (!this.activeEditor) { + return null; + } + + if (this.isListVariant) { + return null; + } + + return 'Currently being edited by'; + } + + get editingMember() { + if (!this.activeEditor) { + return null; + } + + return { + name: this.activeEditor.name, + avatarImage: this.args.post?.editingAvatar || null + }; + } +} diff --git a/ghost/admin/app/components/posts-list/list-item-analytics.hbs b/ghost/admin/app/components/posts-list/list-item-analytics.hbs index f2e91c767d0..215af724bab 100644 --- a/ghost/admin/app/components/posts-list/list-item-analytics.hbs +++ b/ghost/admin/app/components/posts-list/list-item-analytics.hbs @@ -53,6 +53,7 @@ {{/if}}

+ {{/unless}} @@ -160,6 +161,7 @@ {{/if}}

+ {{/unless}} diff --git a/ghost/admin/app/components/posts-list/list-item.hbs b/ghost/admin/app/components/posts-list/list-item.hbs index 9bc0e867f70..6d47cf8e129 100644 --- a/ghost/admin/app/components/posts-list/list-item.hbs +++ b/ghost/admin/app/components/posts-list/list-item.hbs @@ -40,10 +40,11 @@ and sent to {{gh-pluralize @post.email.emailCount "member"}} {{else}} and sent + {{/if}} {{/if}} - {{/if}} - +

+ {{/unless}} {{else}} @@ -137,10 +138,11 @@ {{#if this.isHovered}} to {{gh-pluralize @post.email.emailCount "member"}} {{/if}} - {{/if}} - - {{/if}} + {{/if}} + + {{/if}}

+ {{/unless}} {{/if}} diff --git a/ghost/admin/app/controllers/lexical-editor.js b/ghost/admin/app/controllers/lexical-editor.js index 49049e55b46..3ff13574d2e 100644 --- a/ghost/admin/app/controllers/lexical-editor.js +++ b/ghost/admin/app/controllers/lexical-editor.js @@ -38,6 +38,7 @@ const DUPLICATED_POST_TITLE_SUFFIX = '(Copy)'; const AUTOSAVE_TIMEOUT = 3000; // time in ms to force a save if the user is continuously typing const TIMEDSAVE_TIMEOUT = 60000; +const EDITING_LEASE_INTERVAL = 30000; const TK_REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK)+[^\p{L}\p{N}\s]*)(.)?/u); const WORD_CHAR_REGEX = new RegExp(/\p{L}|\p{N}/u); @@ -160,6 +161,7 @@ export default class LexicalEditorController extends Controller { @service settings; @service ui; @service localRevisions; + @service postEditing; @inject config; @@ -185,6 +187,7 @@ export default class LexicalEditorController extends Controller { _leaveConfirmed = false; _saveOnLeavePerformed = false; _previousTagNames = null; // set by setPost and _postSaved, used in hasDirtyAttributes + editingSessionId = null; /* debug properties ------------------------------------------------------*/ @@ -913,6 +916,8 @@ export default class LexicalEditorController extends Controller { if (titlesMatch && bodiesMatch) { this.set('hasDirtyAttributes', false); } + + this._startEditingLease(post); } @task @@ -1071,6 +1076,7 @@ export default class LexicalEditorController extends Controller { // don't do anything else if we're setting the same post if (post === this.post) { this.set('shouldFocusTitle', post.get('isNew')); + this._startEditingLease(post); return; } @@ -1090,6 +1096,7 @@ export default class LexicalEditorController extends Controller { // TODO: can these be `boundOneWay` on the model as per the other attrs? post.set('titleScratch', post.get('title')); post.set('lexicalScratch', post.get('lexical')); + this._startEditingLease(post); this._previousTagNames = this._tagNames; @@ -1250,10 +1257,14 @@ export default class LexicalEditorController extends Controller { // called when the editor route is left or the post model is swapped reset() { let post = this.post; + const editingSessionId = this.editingSessionId; + const postId = post?.id; + const postType = post?.displayName; // make sure the save tasks aren't still running in the background // after leaving the edit route this.cancelAutosave(); + this.editingLeaseTask.cancelAll(); if (post) { // clear post of any unsaved, client-generated tags @@ -1267,9 +1278,16 @@ export default class LexicalEditorController extends Controller { } } + if (postId && editingSessionId) { + this.postEditing.release({postId, postType, sessionId: editingSessionId}).catch(() => { + // ignore lease release failures and rely on TTL expiry + }); + } + this._previousTagNames = []; this._leaveConfirmed = false; this._saveOnLeavePerformed = false; + this.editingSessionId = null; this._setPostState = null; this._postStates = []; @@ -1318,6 +1336,20 @@ export default class LexicalEditorController extends Controller { }).drop()) _timedSaveTask; + @restartableTask + *editingLeaseTask(postId, postType, sessionId) { + while (this.post?.id === postId && this.editingSessionId === sessionId) { + try { + const lease = yield this.postEditing.touch({postId, postType, sessionId}); + this._applyEditingLease(lease); + } catch (error) { + // ignore presence errors, this is informational only + } + + yield timeout(config.environment === 'test' ? 100 : EDITING_LEASE_INTERVAL); + } + } + /* Private methods -------------------------------------------------------*/ _assignLexicalDiffToLeaveModalReason() { @@ -1360,6 +1392,31 @@ export default class LexicalEditorController extends Controller { } } + _applyEditingLease(lease) { + if (!lease || !this.post || lease.id !== this.post.id) { + return; + } + + this.post.setProperties({ + editingBy: lease.editing_by || null, + editingName: lease.editing_name || null, + editingAvatar: lease.editing_avatar || null, + editingHeartbeatAt: lease.editing_heartbeat_at ? moment.utc(lease.editing_heartbeat_at) : null + }); + } + + _startEditingLease(post = this.post) { + if (!post || post.isNew || !post.id) { + return; + } + + if (!this.editingSessionId) { + this.editingSessionId = this.postEditing.generateSessionId(); + } + + this.editingLeaseTask.perform(post.id, post.displayName, this.editingSessionId); + } + _hasDirtyAttributes() { let post = this.post; diff --git a/ghost/admin/app/models/post.js b/ghost/admin/app/models/post.js index 6fa731f5018..7a404bf7f6a 100644 --- a/ghost/admin/app/models/post.js +++ b/ghost/admin/app/models/post.js @@ -120,6 +120,10 @@ export default Model.extend(Comparable, ValidationEngine, { featureImageAlt: attr('string'), featureImageCaption: attr('string'), showTitleAndFeatureImage: attr('boolean', {defaultValue: true}), + editingBy: attr('string'), + editingName: attr('string'), + editingAvatar: attr('string'), + editingHeartbeatAt: attr('moment-utc'), authors: hasMany('user', {embedded: 'always', async: false}), email: belongsTo('email', {async: false}), diff --git a/ghost/admin/app/serializers/post.js b/ghost/admin/app/serializers/post.js index d5686a1ee18..7e7cc607cc4 100644 --- a/ghost/admin/app/serializers/post.js +++ b/ghost/admin/app/serializers/post.js @@ -9,6 +9,10 @@ export default class PostSerializer extends ApplicationSerializer.extend(Embedde publishedAtUTC: {key: 'published_at'}, createdAtUTC: {key: 'created_at'}, updatedAtUTC: {key: 'updated_at'}, + editingBy: {key: 'editing_by'}, + editingName: {key: 'editing_name'}, + editingAvatar: {key: 'editing_avatar'}, + editingHeartbeatAt: {key: 'editing_heartbeat_at'}, email: {embedded: 'always'}, newsletter: {embedded: 'always'}, postRevisions: {embedded: 'always'} @@ -27,6 +31,10 @@ export default class PostSerializer extends ApplicationSerializer.extend(Embedde delete json.email; delete json.newsletter; delete json.post_revisions; + delete json.editing_by; + delete json.editing_name; + delete json.editing_avatar; + delete json.editing_heartbeat_at; // Deprecated property (replaced with data.authors) delete json.author; // Page-only properties diff --git a/ghost/admin/app/services/post-editing.js b/ghost/admin/app/services/post-editing.js new file mode 100644 index 00000000000..8e7871efb9e --- /dev/null +++ b/ghost/admin/app/services/post-editing.js @@ -0,0 +1,39 @@ +import Service from '@ember/service'; +import {inject as service} from '@ember/service'; + +export default class PostEditingService extends Service { + @service ajax; + @service ghostPaths; + + generateSessionId() { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + + return `editing-${Date.now()}-${Math.random().toString(16).slice(2)}`; + } + + async touch({postId, postType, sessionId}) { + const url = new URL(this.ghostPaths.url.api(this._resourcePath(postType), postId, 'editing'), window.location.href); + url.searchParams.set('session_id', sessionId); + + const response = await this.ajax.request(url.href, { + method: 'POST' + }); + + return response[this._resourcePath(postType)]?.[0] || null; + } + + async release({postId, postType, sessionId}) { + const url = new URL(this.ghostPaths.url.api(this._resourcePath(postType), postId, 'editing'), window.location.href); + url.searchParams.set('session_id', sessionId); + + await this.ajax.request(url.href, { + method: 'DELETE' + }); + } + + _resourcePath(postType) { + return postType === 'page' ? 'pages' : 'posts'; + } +} diff --git a/ghost/admin/app/styles/layouts/content.css b/ghost/admin/app/styles/layouts/content.css index f8b9ee72e5e..2d8728f8938 100644 --- a/ghost/admin/app/styles/layouts/content.css +++ b/ghost/admin/app/styles/layouts/content.css @@ -991,14 +991,14 @@ } .gh-content-entry-status .draft { - display: flex; + display: inline-flex; align-items: center; color: var(--pink); font-weight: 500; } .gh-content-entry-status .scheduled { - display: flex; + display: inline-flex; align-items: center; color: var(--green); font-weight: 500; @@ -1027,6 +1027,74 @@ font-weight: 500; } +.gh-post-editing-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + padding: 4px 14px 4px 10px; + color: var(--darkgrey); + font-size: 1.25rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; + vertical-align: middle; + background: color-mod(var(--yellow) a(6%)); + border: 1px solid color-mod(var(--yellow) a(30%)); + border-radius: 999px; +} + +.gh-post-editing-indicator-prefix { + color: color-mod(var(--yellow) l(-26%)); + font-size: 1.15rem; + font-weight: 600; +} + +.gh-post-editing-indicator-avatar { + width: 22px; + height: 22px; + min-width: 22px; + box-shadow: none; +} + +.gh-post-editing-indicator-avatar-label { + font-size: 1rem; + letter-spacing: -0.4px; +} + +.gh-post-editing-indicator-label { + color: var(--darkgrey); + font-weight: 700; +} + +.gh-post-editing-indicator--list { + display: flex; + align-items: center; + gap: 6px; + margin: 4px 0 0; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + color: var(--midgrey-d1); + font-size: 1.25rem; + font-weight: 500; + line-height: 1.35; + white-space: normal; +} + +.gh-post-editing-indicator--list svg { + width: 1.3rem; + height: 1.3rem; + min-width: 1.3rem; + fill: var(--midlightgrey-d1); +} + +.gh-post-editing-indicator--list .gh-post-editing-indicator-label { + color: var(--midgrey-d1); + font-weight: 500; +} + .schedule-details { margin-left: 3px; color: var(--midlightgrey-d1); diff --git a/ghost/admin/app/styles/layouts/editor.css b/ghost/admin/app/styles/layouts/editor.css index f5db7ba70b5..75609bb843c 100644 --- a/ghost/admin/app/styles/layouts/editor.css +++ b/ghost/admin/app/styles/layouts/editor.css @@ -1064,6 +1064,21 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { transform: translateY(-1px); } +.gh-editor-post-status .gh-post-editing-indicator { + margin-left: 10px; + font-size: 1.2rem; +} + +.gh-editor-post-status .gh-post-editing-indicator-prefix { + font-size: 1.05rem; +} + +.gh-editor-post-status .gh-post-editing-indicator-avatar { + width: 22px; + height: 22px; + min-width: 22px; +} + @media (max-width: 720px) { .gh-editor-post-status .newsletter-failed { display: none; diff --git a/ghost/core/core/server/api/endpoints/pages.js b/ghost/core/core/server/api/endpoints/pages.js index b363d862a7d..e7f808c67a7 100644 --- a/ghost/core/core/server/api/endpoints/pages.js +++ b/ghost/core/core/server/api/endpoints/pages.js @@ -175,6 +175,76 @@ const controller = { } }, + touchEditing: { + statusCode: 200, + headers: { + cacheInvalidate: false + }, + options: [ + 'id', + 'session_id' + ], + validation: { + options: { + id: { + required: true + }, + session_id: { + required: true + } + } + }, + permissions: { + docName: 'posts', + method: 'edit', + unsafeAttrs: UNSAFE_ATTRS + }, + query(frame) { + return postsService.touchEditing({ + id: frame.options.id, + type: 'page', + context: frame.options.context, + sessionId: frame.options.session_id + }); + } + }, + + clearEditing: { + statusCode: 204, + headers: { + cacheInvalidate: false + }, + options: [ + 'id', + 'session_id' + ], + validation: { + options: { + id: { + required: true + }, + session_id: { + required: true + } + } + }, + permissions: { + docName: 'posts', + method: 'edit', + unsafeAttrs: UNSAFE_ATTRS + }, + async query(frame) { + await postsService.clearEditing({ + id: frame.options.id, + type: 'page', + context: frame.options.context, + sessionId: frame.options.session_id + }); + + return null; + } + }, + bulkEdit: { statusCode: 200, headers: { diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index c47fc1db3fa..db143282aba 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -232,6 +232,74 @@ const controller = { } }, + touchEditing: { + statusCode: 200, + headers: { + cacheInvalidate: false + }, + options: [ + 'id', + 'session_id' + ], + validation: { + options: { + id: { + required: true + }, + session_id: { + required: true + } + } + }, + permissions: { + method: 'edit', + unsafeAttrs: unsafeAttrs + }, + query(frame) { + return postsService.touchEditing({ + id: frame.options.id, + type: 'post', + context: frame.options.context, + sessionId: frame.options.session_id + }); + } + }, + + clearEditing: { + statusCode: 204, + headers: { + cacheInvalidate: false + }, + options: [ + 'id', + 'session_id' + ], + validation: { + options: { + id: { + required: true + }, + session_id: { + required: true + } + } + }, + permissions: { + method: 'edit', + unsafeAttrs: unsafeAttrs + }, + async query(frame) { + await postsService.clearEditing({ + id: frame.options.id, + type: 'post', + context: frame.options.context, + sessionId: frame.options.session_id + }); + + return null; + } + }, + bulkEdit: { statusCode: 200, headers: { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js index 564e086a2e8..64e19647c6e 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/pages.js @@ -53,6 +53,12 @@ module.exports = { }; }, + touchEditing(editing, _apiConfig, frame) { + frame.response = { + pages: [editing] + }; + }, + bulkEdit(bulkActionResult, _apiConfig, frame) { frame.response = { bulk: { diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js index 97e129c418b..fd79e0385f0 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js @@ -53,6 +53,12 @@ module.exports = { }; }, + touchEditing(editing, _apiConfig, frame) { + frame.response = { + posts: [editing] + }; + }, + exportCSV(models, apiConfig, frame) { frame.response = papaparse.unparse(models.data); }, diff --git a/ghost/core/core/server/data/migrations/versions/6.24/2026-03-27-12-00-00-add-post-editing-presence-columns.js b/ghost/core/core/server/data/migrations/versions/6.24/2026-03-27-12-00-00-add-post-editing-presence-columns.js new file mode 100644 index 00000000000..9de7b804bcb --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.24/2026-03-27-12-00-00-add-post-editing-presence-columns.js @@ -0,0 +1,28 @@ +const {combineNonTransactionalMigrations, createAddColumnMigration} = require('../../utils'); + +module.exports = combineNonTransactionalMigrations( + createAddColumnMigration('posts_meta', 'editing_by', { + type: 'string', + maxlength: 24, + nullable: true + }), + createAddColumnMigration('posts_meta', 'editing_name', { + type: 'string', + maxlength: 191, + nullable: true + }), + createAddColumnMigration('posts_meta', 'editing_avatar', { + type: 'string', + maxlength: 2000, + nullable: true + }), + createAddColumnMigration('posts_meta', 'editing_session_id', { + type: 'string', + maxlength: 50, + nullable: true + }), + createAddColumnMigration('posts_meta', 'editing_heartbeat_at', { + type: 'dateTime', + nullable: true + }) +); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index f207867fd67..5340fe63ca2 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -119,7 +119,12 @@ module.exports = { frontmatter: {type: 'text', maxlength: 65535, nullable: true}, feature_image_alt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 191}}}, feature_image_caption: {type: 'text', maxlength: 65535, nullable: true}, - email_only: {type: 'boolean', nullable: false, defaultTo: false} + email_only: {type: 'boolean', nullable: false, defaultTo: false}, + editing_by: {type: 'string', maxlength: 24, nullable: true}, + editing_name: {type: 'string', maxlength: 191, nullable: true}, + editing_avatar: {type: 'string', maxlength: 2000, nullable: true}, + editing_session_id: {type: 'string', maxlength: 50, nullable: true}, + editing_heartbeat_at: {type: 'dateTime', nullable: true} }, // NOTE: this is the staff table users: { diff --git a/ghost/core/core/server/services/posts/posts-service.js b/ghost/core/core/server/services/posts/posts-service.js index 20f912e9242..fabc04e0551 100644 --- a/ghost/core/core/server/services/posts/posts-service.js +++ b/ghost/core/core/server/services/posts/posts-service.js @@ -3,6 +3,7 @@ const {BadRequestError} = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const ObjectId = require('bson-objectid').default; +const db = require('../../data/db'); const pick = require('lodash/pick'); const DomainEvents = require('@tryghost/domain-events'); const PostEmailHandler = require('./post-email-handler'); @@ -16,6 +17,8 @@ const messages = { postNotFound: 'Post not found.' }; +const EDITING_TTL_MS = 2 * 60 * 1000; + class PostsService { constructor({urlUtils, models, isSet, stats, emailService, postsExporter}) { this.urlUtils = urlUtils; @@ -67,6 +70,13 @@ class PostsService { await this.postEmailHandler.createOrRetryEmail(model); + try { + // Presence is informational and should not block a successful save. + await this.#refreshEditingLeaseAfterSave(model, frame.options?.context); + } catch { + // ignore presence refresh failures + } + const dto = model.toJSON(frame.options); if (typeof options?.eventHandler === 'function') { @@ -76,6 +86,77 @@ class PostsService { return dto; } + async touchEditing({id, type, context, sessionId}) { + const post = await this.models.Post.findOne( + {id, type, status: 'all'}, + {context, withRelated: ['posts_meta'], require: false} + ); + + if (!post) { + throw new errors.NotFoundError({ + message: tpl(messages.postNotFound) + }); + } + + const userId = context?.user; + const user = userId ? await this.models.User.findOne({id: userId}, {require: false}) : null; + const postsMeta = post.related('posts_meta'); + const currentLease = this.#serializeEditingMeta(postsMeta, post.id); + + if (!user) { + return currentLease; + } + + if (this.#canClaimEditingLease(postsMeta, user.get('id'), sessionId)) { + const now = new Date(); + const lease = { + editing_by: user.get('id'), + editing_name: user.get('name'), + editing_avatar: user.get('profile_image'), + editing_session_id: sessionId, + editing_heartbeat_at: now + }; + + await this.#upsertPostMeta(post.id, lease); + + return this.#serializeEditingMeta({ + get(key) { + return lease[key]; + } + }, post.id); + } + + return currentLease; + } + + async clearEditing({id, type, context, sessionId}) { + const post = await this.models.Post.findOne( + {id, type, status: 'all'}, + {context, withRelated: ['posts_meta'], require: false} + ); + + if (!post) { + throw new errors.NotFoundError({ + message: tpl(messages.postNotFound) + }); + } + + const postsMeta = post.related('posts_meta'); + const currentSessionId = postsMeta?.get('editing_session_id'); + + if (!currentSessionId || currentSessionId !== sessionId) { + return; + } + + await this.#upsertPostMeta(post.id, { + editing_by: null, + editing_name: null, + editing_avatar: null, + editing_session_id: null, + editing_heartbeat_at: null + }); + } + /** * @param {any} model * @returns {EventString} @@ -98,6 +179,100 @@ class PostsService { } } + #isLeaseActive(heartbeatAt) { + if (!heartbeatAt) { + return false; + } + + return (Date.now() - new Date(heartbeatAt).getTime()) < EDITING_TTL_MS; + } + + #serializeEditingMeta(postsMeta, postId = null) { + const id = postId || postsMeta?.get?.('post_id') || null; + const heartbeatAt = postsMeta?.get?.('editing_heartbeat_at') || null; + + if (!this.#isLeaseActive(heartbeatAt)) { + return { + id, + editing_by: null, + editing_name: null, + editing_avatar: null, + editing_heartbeat_at: null + }; + } + + return { + id, + editing_by: postsMeta.get('editing_by') || null, + editing_name: postsMeta.get('editing_name') || null, + editing_avatar: postsMeta.get('editing_avatar') || null, + editing_heartbeat_at: new Date(heartbeatAt).toISOString() + }; + } + + #canClaimEditingLease(postsMeta, userId, sessionId) { + const currentLease = this.#serializeEditingMeta(postsMeta); + + if (!currentLease.editing_heartbeat_at) { + return true; + } + + return currentLease.editing_by === userId || postsMeta?.get('editing_session_id') === sessionId; + } + + async #refreshEditingLeaseAfterSave(model, context) { + const userId = context?.user; + const postId = model?.get?.('id') || model?.id; + const postType = model?.get?.('type') || model?.attributes?.type; + + if (!userId || !postId || !postType) { + return; + } + + const user = await this.models.User.findOne({id: userId}, {require: false}); + + if (!user) { + return; + } + + const post = await this.models.Post.findOne( + {id: postId, type: postType, status: 'all'}, + {context, withRelated: ['posts_meta'], require: false} + ); + + if (!post) { + return; + } + + const postsMeta = post.related('posts_meta'); + const currentLease = this.#serializeEditingMeta(postsMeta, post.id); + + if (currentLease.editing_heartbeat_at && currentLease.editing_by !== user.get('id')) { + return; + } + + const shouldKeepSessionId = currentLease.editing_by === user.get('id'); + + await this.#upsertPostMeta(post.id, { + editing_by: user.get('id'), + editing_name: user.get('name'), + editing_avatar: user.get('profile_image'), + editing_session_id: shouldKeepSessionId ? (postsMeta?.get('editing_session_id') || null) : null, + editing_heartbeat_at: new Date() + }); + } + + async #upsertPostMeta(postId, data) { + await db.knex('posts_meta') + .insert({ + id: this.models.Post.generateId(), + post_id: postId, + ...data + }) + .onConflict('post_id') + .merge(data); + } + #mergeFilters(...filters) { return filters.filter(filter => filter).map(f => `(${f})`).join('+'); } diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 733ec22e05a..2d0515f27a6 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -37,6 +37,8 @@ module.exports = function apiRoutes() { router.put('/posts/bulk', mw.authAdminApi, http(api.posts.bulkEdit)); router.get('/posts/:id', mw.authAdminApi, http(api.posts.read)); router.get('/posts/slug/:slug', mw.authAdminApi, http(api.posts.read)); + router.post('/posts/:id/editing', mw.authAdminApi, http(api.posts.touchEditing)); + router.del('/posts/:id/editing', mw.authAdminApi, http(api.posts.clearEditing)); router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit)); router.del('/posts/:id', mw.authAdminApi, http(api.posts.destroy)); router.post('/posts/:id/copy', mw.authAdminApi, http(api.posts.copy)); @@ -60,6 +62,8 @@ module.exports = function apiRoutes() { router.post('/pages', mw.authAdminApi, http(api.pages.add)); router.get('/pages/:id', mw.authAdminApi, http(api.pages.read)); router.get('/pages/slug/:slug', mw.authAdminApi, http(api.pages.read)); + router.post('/pages/:id/editing', mw.authAdminApi, http(api.pages.touchEditing)); + router.del('/pages/:id/editing', mw.authAdminApi, http(api.pages.clearEditing)); router.put('/pages/:id', mw.authAdminApi, http(api.pages.edit)); router.del('/pages/:id', mw.authAdminApi, http(api.pages.destroy)); router.post('/pages/:id/copy', mw.authAdminApi, http(api.pages.copy)); diff --git a/ghost/core/test/unit/server/services/posts/posts-service.test.js b/ghost/core/test/unit/server/services/posts/posts-service.test.js index 5af89006108..eeed89b043b 100644 --- a/ghost/core/test/unit/server/services/posts/posts-service.test.js +++ b/ghost/core/test/unit/server/services/posts/posts-service.test.js @@ -1,5 +1,6 @@ const PostsService = require('../../../../../core/server/services/posts/posts-service'); const assert = require('node:assert/strict'); +const db = require('../../../../../core/server/data/db'); const sinon = require('sinon'); describe('Posts Service', function () { @@ -268,7 +269,11 @@ describe('Posts Service', function () { mockModels = { Post: { findOne: sinon.stub(), - edit: sinon.stub() + edit: sinon.stub(), + generateId: sinon.stub().returns('meta-id') + }, + User: { + findOne: sinon.stub() }, Member: { findPage: sinon.stub() @@ -360,6 +365,12 @@ describe('Posts Service', function () { const postData = {id: 'post-123', title: 'Test Post'}; const model = { get: sinon.stub().callsFake((key) => { + if (key === 'id') { + return 'post-123'; + } + if (key === 'type') { + return 'post'; + } if (key === 'newsletter_id') { return null; } @@ -384,6 +395,203 @@ describe('Posts Service', function () { assert.deepEqual(result, postData); sinon.assert.calledOnceWithExactly(eventHandler, 'published_updated', postData); }); + + it('refreshes the editing lease on save when there is no active editor', async function () { + const now = new Date(Date.now() - (3 * 60 * 1000)); + const postData = {id: 'post-123', title: 'Test Post'}; + const model = { + get: sinon.stub().callsFake((key) => { + if (key === 'id') { + return 'post-123'; + } + if (key === 'type') { + return 'post'; + } + if (key === 'status') { + return 'draft'; + } + return null; + }), + previous: sinon.stub().returns('draft'), + toJSON: sinon.stub().returns(postData), + wasChanged: sinon.stub().returns(true) + }; + const postsMeta = { + get(key) { + if (key === 'editing_by') { + return 'old-user'; + } + if (key === 'editing_name') { + return 'Old User'; + } + if (key === 'editing_avatar') { + return '/content/images/old.png'; + } + if (key === 'editing_session_id') { + return 'old-session'; + } + if (key === 'editing_heartbeat_at') { + return now; + } + return null; + } + }; + const savedPost = { + id: 'post-123', + related: sinon.stub().withArgs('posts_meta').returns(postsMeta) + }; + const user = { + get: sinon.stub().callsFake((key) => { + if (key === 'id') { + return 'user-1'; + } + if (key === 'name') { + return 'New User'; + } + if (key === 'profile_image') { + return '/content/images/new.png'; + } + return null; + }) + }; + const queryBuilder = { + insert: sinon.stub().returnsThis(), + onConflict: sinon.stub().returnsThis(), + merge: sinon.stub().resolves() + }; + const fakeKnex = sinon.stub().returns(queryBuilder); + + mockModels.Post.edit.resolves(model); + mockModels.Post.findOne.resolves(savedPost); + mockModels.User.findOne.resolves(user); + sinon.stub(db, 'knex').get(() => fakeKnex); + + const frame = { + data: {posts: [{status: 'draft'}]}, + options: { + id: 'post-123', + context: { + user: 'user-1' + } + } + }; + + await postsService.editPost(frame); + + sinon.assert.calledOnceWithExactly(mockModels.Post.findOne, { + id: 'post-123', + type: 'post', + status: 'all' + }, { + context: frame.options.context, + withRelated: ['posts_meta'], + require: false + }); + sinon.assert.calledOnceWithExactly(mockModels.User.findOne, {id: 'user-1'}, {require: false}); + sinon.assert.calledOnceWithExactly(fakeKnex, 'posts_meta'); + sinon.assert.calledOnce(queryBuilder.insert); + assert.equal(queryBuilder.insert.firstCall.args[0].id, 'meta-id'); + assert.equal(queryBuilder.insert.firstCall.args[0].post_id, 'post-123'); + assert.equal(queryBuilder.insert.firstCall.args[0].editing_by, 'user-1'); + assert.equal(queryBuilder.insert.firstCall.args[0].editing_name, 'New User'); + assert.equal(queryBuilder.insert.firstCall.args[0].editing_avatar, '/content/images/new.png'); + assert.equal(queryBuilder.insert.firstCall.args[0].editing_session_id, null); + assert.ok(queryBuilder.insert.firstCall.args[0].editing_heartbeat_at instanceof Date); + sinon.assert.calledOnceWithExactly(queryBuilder.onConflict, 'post_id'); + sinon.assert.calledOnce(queryBuilder.merge); + assert.equal(queryBuilder.merge.firstCall.args[0].editing_by, 'user-1'); + assert.equal(queryBuilder.merge.firstCall.args[0].editing_name, 'New User'); + assert.equal(queryBuilder.merge.firstCall.args[0].editing_avatar, '/content/images/new.png'); + assert.equal(queryBuilder.merge.firstCall.args[0].editing_session_id, null); + assert.ok(queryBuilder.merge.firstCall.args[0].editing_heartbeat_at instanceof Date); + }); + + it('does not steal the editing lease from another active editor on save', async function () { + const postData = {id: 'post-123', title: 'Test Post'}; + const model = { + get: sinon.stub().callsFake((key) => { + if (key === 'id') { + return 'post-123'; + } + if (key === 'type') { + return 'post'; + } + if (key === 'status') { + return 'draft'; + } + return null; + }), + previous: sinon.stub().returns('draft'), + toJSON: sinon.stub().returns(postData), + wasChanged: sinon.stub().returns(true) + }; + const postsMeta = { + get(key) { + if (key === 'editing_by') { + return 'user-2'; + } + if (key === 'editing_name') { + return 'Other User'; + } + if (key === 'editing_avatar') { + return '/content/images/other.png'; + } + if (key === 'editing_session_id') { + return 'other-session'; + } + if (key === 'editing_heartbeat_at') { + return new Date(); + } + return null; + } + }; + const savedPost = { + id: 'post-123', + related: sinon.stub().withArgs('posts_meta').returns(postsMeta) + }; + const user = { + get: sinon.stub().callsFake((key) => { + if (key === 'id') { + return 'user-1'; + } + if (key === 'name') { + return 'New User'; + } + if (key === 'profile_image') { + return '/content/images/new.png'; + } + return null; + }) + }; + const queryBuilder = { + insert: sinon.stub().returnsThis(), + onConflict: sinon.stub().returnsThis(), + merge: sinon.stub().resolves() + }; + const fakeKnex = sinon.stub().returns(queryBuilder); + + mockModels.Post.edit.resolves(model); + mockModels.Post.findOne.resolves(savedPost); + mockModels.User.findOne.resolves(user); + sinon.stub(db, 'knex').get(() => fakeKnex); + + const frame = { + data: {posts: [{status: 'draft'}]}, + options: { + id: 'post-123', + context: { + user: 'user-1' + } + } + }; + + await postsService.editPost(frame); + + sinon.assert.notCalled(fakeKnex); + sinon.assert.notCalled(queryBuilder.insert); + sinon.assert.notCalled(queryBuilder.onConflict); + sinon.assert.notCalled(queryBuilder.merge); + }); }); describe('getChanges', function () {