diff --git a/packages/bookshelf-collision/lib/bookshelf-collision.js b/packages/bookshelf-collision/lib/bookshelf-collision.js index 865995471..6df2a8321 100644 --- a/packages/bookshelf-collision/lib/bookshelf-collision.js +++ b/packages/bookshelf-collision/lib/bookshelf-collision.js @@ -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 @@ -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.', diff --git a/packages/bookshelf-collision/package.json b/packages/bookshelf-collision/package.json index a447714cd..4356897a2 100644 --- a/packages/bookshelf-collision/package.json +++ b/packages/bookshelf-collision/package.json @@ -25,7 +25,7 @@ "dependencies": { "@tryghost/errors": "workspace:^", "lodash": "^4.18.1", - "moment-timezone": "^0.5.33" + "luxon": "^3.7.2" }, "devDependencies": { "sinon": "catalog:" diff --git a/packages/bookshelf-collision/test/bookshelf-collision.test.js b/packages/bookshelf-collision/test/bookshelf-collision.test.js index 33b9baa38..95700acee 100644 --- a/packages/bookshelf-collision/test/bookshelf-collision.test.js +++ b/packages/bookshelf-collision/test/bookshelf-collision.test.js @@ -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(); diff --git a/packages/validator/lib/validator.js b/packages/validator/lib/validator.js index c6650c185..17b93a69e 100644 --- a/packages/validator/lib/validator.js +++ b/packages/validator/lib/validator.js @@ -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'); @@ -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) { diff --git a/packages/validator/package.json b/packages/validator/package.json index 93bce1ede..0e80c4594 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -26,7 +26,6 @@ "@tryghost/errors": "workspace:^", "@tryghost/tpl": "workspace:^", "lodash": "^4.18.1", - "moment-timezone": "^0.5.23", "validator": "^13.15.35" } } diff --git a/packages/validator/test/internals.test.js b/packages/validator/test/internals.test.js index 2dc13b2a6..26f22ff06 100644 --- a/packages/validator/test/internals.test.js +++ b/packages/validator/test/internals.test.js @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e57b16d19..1b27d9599 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,9 +217,9 @@ importers: lodash: specifier: ^4.18.1 version: 4.18.1 - moment-timezone: - specifier: ^0.5.33 - version: 0.5.48 + luxon: + specifier: ^3.7.2 + version: 3.7.2 devDependencies: sinon: specifier: 'catalog:' @@ -851,9 +851,6 @@ importers: lodash: specifier: ^4.18.1 version: 4.18.1 - moment-timezone: - specifier: ^0.5.23 - version: 0.5.48 validator: specifier: ^13.15.35 version: 13.15.35 @@ -4363,9 +4360,6 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true - moment-timezone@0.5.48: - resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==} - moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} @@ -8861,11 +8855,8 @@ snapshots: minimist: 1.2.8 optional: true - moment-timezone@0.5.48: - dependencies: - moment: 2.30.1 - - moment@2.30.1: {} + moment@2.30.1: + optional: true ms@2.0.0: {}