fix(dates): normalize SQLite epoch timestamps at remaining API surfaces (#485 follow-up) (#857)

The audit #485 called for: on SQLite (native installs), timestamp
columns written with a raw `new Date()` through knex store epoch-ms
numbers; Postgres returns ISO strings. Frontend code written against
Postgres calls parseISO() on them — parseISO(number) throws and crashes
the page. #485 fixed admin Users and listed api tokens / photos /
activity as out-of-scope follow-ups.

Verified crash on main: Timeline gallery layout parseISO(uploaded_at)
against photos written by the archive-RESTORE path (raw Date). Other
raw-write surfaces (api_tokens last_used_at/revoked_at, email_queue)
degrade rather than crash but violate the ISO contract.

- extract toIso() from adminUsers.js into utils/dateNormalize.js
  (contract unchanged — the 10 existing #485 tests still pin it)
- write-side: archive-restore uploaded_at, api-token last_used_at /
  revoked_at, email_queue created_at/sent_at now write ISO strings
- read-side (heals existing corrupted rows): gallery /photos normalizes
  uploaded_at/captured_at; api-tokens list normalizes all four
  timestamp fields
- frontend defence-in-depth: Timeline layout parses uploaded_at
  tolerantly (typeof guard) for stale caches / old backends
- 2 regression tests seed literal epoch numbers and assert the API
  serves ISO strings

activity_logs turned out safe (created_at comes from the DB default,
not a raw Date) — left untouched.

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-22 21:07:32 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 2f05fcc39d
commit c6ec93eef9
10 changed files with 207 additions and 45 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ async function apiTokenAuth(req, res, next) {
}
// Touch last_used_at — async, don't block the request.
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() })
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date().toISOString() })
.catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message }));
req.admin = admin;
+11 -2
View File
@@ -11,6 +11,7 @@ const { db, logActivity } = require('../database/db');
const { adminAuth } = require('./../middleware/auth');
const { requirePermission } = require('./../middleware/permissions');
const { generateApiToken, VALID_SCOPES } = require('./../middleware/apiTokenAuth');
const { toIso } = require('../utils/dateNormalize');
const logger = require('../utils/logger');
const router = express.Router();
@@ -33,7 +34,15 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
'admin_users.username as owner_username'
)
.orderBy('api_tokens.created_at', 'desc');
res.json(tokens);
// toIso: last_used_at / revoked_at were written as raw Dates before
// this fix — SQLite installs hold epoch numbers in existing rows.
res.json(tokens.map((t) => ({
...t,
created_at: toIso(t.created_at),
expires_at: toIso(t.expires_at),
last_used_at: toIso(t.last_used_at),
revoked_at: toIso(t.revoked_at),
})));
} catch (error) {
logger.error('Failed to list API tokens', { error: error.message });
res.status(500).json({ error: 'Failed to list tokens' });
@@ -103,7 +112,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
if (!row) return res.status(404).json({ error: 'Token not found' });
if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' });
await db('api_tokens').where({ id }).update({ revoked_at: new Date() });
await db('api_tokens').where({ id }).update({ revoked_at: new Date().toISOString() });
await logActivity('api_token_revoked', { name: row.name }, null, {
type: 'admin', id: req.admin.id, name: req.admin.username
});
+1 -1
View File
@@ -293,7 +293,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
type: path.extname(filename).substring(1).toLowerCase(),
size_bytes: stats.size,
category_id: categoryId,
uploaded_at: new Date()
uploaded_at: new Date().toISOString()
});
}
} catch (statError) {
+5 -5
View File
@@ -419,7 +419,7 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
account_key: b.account_key,
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_pass : '',
created_at: new Date(),
created_at: new Date().toISOString(),
...patch,
});
}
@@ -808,8 +808,8 @@ router.post('/send', adminAuth, messagingGate, requirePermission('email.send'),
status: 'sent',
origin: 'manual',
rendered_html: html,
created_at: new Date(),
sent_at: new Date(),
created_at: new Date().toISOString(),
sent_at: new Date().toISOString(),
});
res.json({ ok: true });
} catch (error) {
@@ -985,7 +985,7 @@ router.put('/templates/:key', [
template_id: template.id,
language,
...row,
created_at: new Date(),
created_at: new Date().toISOString(),
});
}
}
@@ -1099,7 +1099,7 @@ router.post('/templates', [
subject: content.subject || '',
body_html: content.body_html || '',
body_text: content.body_text || '',
created_at: new Date(),
created_at: new Date().toISOString(),
updated_at: new Date(),
});
}
+1 -31
View File
@@ -10,39 +10,9 @@ const { requirePermission, requireSuperAdmin, getUserPermissions } = require('..
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const userManagementService = require('../services/userManagementService');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const { toIso } = require('../utils/dateNormalize');
const router = express.Router();
/**
* Coerce any of the shapes a TIMESTAMP column produces across our
* supported drivers into a single ISO 8601 string the frontend (and
* any external API consumer) can safely pass to date-fns / new Date.
*
* Postgres → Date object (becomes ISO via JSON.stringify anyway, but
* pinning the format defends against driver-side surprises).
* SQLite → integer milliseconds since epoch (the surface that crashed
* the admin Users page in #485 — `parseISO(123456789)` blows up
* with "e.split is not a function"). Native installs default to
* SQLite, so this path matters every release.
* Already a string → assume it's a parseable ISO/RFC3339 (Postgres
* driver may stringify under JSON serialization mid-pipeline).
*
* Returns null/undefined unchanged so an unset last_login surfaces as
* "Never" in the UI rather than 1970-01-01T00:00:00Z.
*/
function toIso(value) {
if (value === null || value === undefined || value === '') return value;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// Numeric-as-string ("1778752458666") happens when the SQLite
// driver stringifies large integers — re-coerce so the frontend
// doesn't try to parseISO('1778752458666').
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
return value;
}
return value;
}
/**
* Transform user object from snake_case (DB) to camelCase (API)
*/
+6 -2
View File
@@ -29,6 +29,7 @@ const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
const { toIso } = require('../utils/dateNormalize');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
@@ -936,14 +937,17 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
: true,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// toIso: on SQLite installs rows written with a raw Date (e.g.
// the pre-fix archive-restore path) hold epoch numbers — the
// Timeline layout's parseISO() crashes on those (#485 class).
uploaded_at: toIso(photo.uploaded_at),
// Image dimensions for layout calculations
width: photo.width || null,
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// EXIF capture date
captured_at: photo.captured_at || null,
captured_at: toIso(photo.captured_at) || null,
// Media type
media_type: photo.media_type || null,
mime_type: photo.mime_type || null,
+1 -1
View File
@@ -941,7 +941,7 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
// Mark as sent, persisting the actual rendered HTML for the Project
// Overview email preview (guarded — older installs without migration
// 119 just skip it).
const sentUpdate = { status: 'sent', sent_at: new Date() };
const sentUpdate = { status: 'sent', sent_at: new Date().toISOString() };
try {
if (sendResult && sendResult.html && await hasColumnCached('email_queue', 'rendered_html')) {
sentUpdate.rendered_html = sendResult.html;
+36
View File
@@ -0,0 +1,36 @@
/**
* Coerce any of the shapes a TIMESTAMP column produces across our
* supported drivers into a single ISO 8601 string the frontend (and
* any external API consumer) can safely pass to date-fns / new Date.
*
* Postgres → Date object (becomes ISO via JSON.stringify anyway, but
* pinning the format defends against driver-side surprises).
* SQLite → integer milliseconds since epoch when a raw `new Date()` was
* written through knex (the surface that crashed the admin Users page
* in #485 — `parseISO(123456789)` blows up with "e.split is not a
* function"). Native installs default to SQLite, so this path matters
* every release.
* Already a string → assume it's a parseable ISO/RFC3339 (Postgres
* driver may stringify under JSON serialization mid-pipeline).
*
* Returns null/undefined unchanged so an unset value surfaces as
* "Never" in the UI rather than 1970-01-01T00:00:00Z.
*
* Extracted from routes/adminUsers.js (#485) so every route that
* serializes timestamps can share one contract.
*/
function toIso(value) {
if (value === null || value === undefined || value === '') return value;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// Numeric-as-string ("1778752458666") happens when the SQLite
// driver stringifies large integers — re-coerce so the frontend
// doesn't try to parseISO('1778752458666').
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
return value;
}
return value;
}
module.exports = { toIso };