Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions packages/bookshelf-collision/lib/bookshelf-collision.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
const moment = require('moment-timezone');
const _ = require('lodash');
const errors = require('@tryghost/errors');
const { DateTime } = require('luxon');

/**
* Normalise an updated_at value, which may be a Date or an ISO/SQL datetime string
* @param {Date|string|number} value
* @returns {DateTime}
*/
function toDateTime(value) {
if (value instanceof Date) {
return DateTime.fromJSDate(value);
}

if (typeof value === 'number') {
return DateTime.fromMillis(value);
}

const iso = DateTime.fromISO(value);
return iso.isValid ? iso : DateTime.fromSQL(value);
}

/**
* @param {import('bookshelf')} Bookshelf
Expand Down Expand Up @@ -61,15 +79,17 @@ module.exports = function (Bookshelf) {
'plaintext',
]);

const clientUpdatedAt = moment(
const clientUpdatedAt = toDateTime(
self.clientData.updated_at || self.serverData.updated_at || new Date(),
);
const serverUpdatedAt = moment(self.serverData.updated_at || clientUpdatedAt);
const serverUpdatedAt = self.serverData.updated_at
? toDateTime(self.serverData.updated_at)
: clientUpdatedAt;

const changedFields = Object.keys(changed);

if (changedFields.length) {
if (clientUpdatedAt.diff(serverUpdatedAt) !== 0) {
if (clientUpdatedAt.toMillis() !== serverUpdatedAt.toMillis()) {
// @NOTE: This will rollback the update. We cannot know if relations were updated before doing the update.
throw new errors.UpdateCollisionError({
message: 'Saving failed! Someone else is editing this post.',
Expand Down
2 changes: 1 addition & 1 deletion packages/bookshelf-collision/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"dependencies": {
"@tryghost/errors": "workspace:^",
"lodash": "^4.18.1",
"moment-timezone": "^0.5.33"
"luxon": "^3.7.2"
},
"devDependencies": {
"sinon": "catalog:"
Expand Down
27 changes: 27 additions & 0 deletions packages/bookshelf-collision/test/bookshelf-collision.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,33 @@ describe('@tryghost/bookshelf-collision', function () {
assert.equal(parentUpdate.calledOnce, true);
});

it('wrapped update compares Date, millisecond and ISO timestamps by instant', async function () {
const Model = Bookshelf.Model;
const model = new Model();
model.tableName = 'posts';
model.serverData = { updated_at: new Date('2024-01-01T00:00:00.000Z') };
model.clientData = { updated_at: Date.parse('2024-01-01T00:00:00.000Z') };
model._changed = { title: 'changed' };

const result = await model.sync({ method: 'update' }).update();

assert.equal(result, 'UPDATED');
});

it('wrapped update parses SQL datetime strings', async function () {
const Model = Bookshelf.Model;
const model = new Model();
model.tableName = 'posts';
model.serverData = { updated_at: '2024-01-01 00:00:00' };
model.clientData = { updated_at: '2024-01-02 00:00:00' };
model._changed = { title: 'changed' };

await assert.rejects(
model.sync({ method: 'update' }).update(),
errors.UpdateCollisionError,
);
});

it('falls back to current date when no timestamps are present and no fields changed', async function () {
const Model = Bookshelf.Model;
const model = new Model();
Expand Down
13 changes: 11 additions & 2 deletions packages/validator/lib/validator.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
const _ = require('lodash');

const baseValidator = require('validator');
const moment = require('moment-timezone');
const assert = require('assert');

const isEmailCustom = require('./is-email');
Expand Down Expand Up @@ -34,7 +33,17 @@ allowedValidators.forEach((name) => {

validators.isTimezone = function isTimezone(str) {
assertString(str);
return moment.tz.zone(str) ? true : false;
// Intl also accepts UTC offsets like "+01:00"; only IANA zone names are valid here
if (!str || /^[+-]/.test(str)) {
return false;
}

try {
new Intl.DateTimeFormat('en-US', { timeZone: str });
return true;
} catch {
return false;
}
};

validators.isEmptyOrURL = function isEmptyOrURL(str) {
Expand Down
1 change: 0 additions & 1 deletion packages/validator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"@tryghost/errors": "workspace:^",
"@tryghost/tpl": "workspace:^",
"lodash": "^4.18.1",
"moment-timezone": "^0.5.23",
"validator": "^13.15.35"
}
}
13 changes: 13 additions & 0 deletions packages/validator/test/internals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ describe('Validator internals', function () {
assert.equal(validator.isTimezone('Not/AZone'), false);
});

it('isTimezone accepts aliases and case-insensitive names', function () {
assert.equal(validator.isTimezone('europe/london'), true);
assert.equal(validator.isTimezone('US/Eastern'), true);
assert.equal(validator.isTimezone('UTC'), true);
assert.equal(validator.isTimezone('Etc/GMT+5'), true);
});

it('isTimezone rejects UTC offsets and empty strings', function () {
assert.equal(validator.isTimezone('+01:00'), false);
assert.equal(validator.isTimezone('-0500'), false);
assert.equal(validator.isTimezone(''), false);
});

it('isSlug validates slug format', function () {
assert.equal(validator.isSlug('a-valid_slug-1'), true);
assert.equal(validator.isSlug('not valid slug'), false);
Expand Down
19 changes: 5 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.