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 () {