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
@@ -41,12 +41,19 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const showDates = gallerySettings.timelineShowDates !== false;
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
// Tolerant timestamp parse: SQLite installs can hold epoch numbers in
// uploaded_at (pre-fix archive restores) — parseISO throws on numbers
// and crashed the whole Timeline view (#485 class). The API normalizes
// to ISO now; this guard covers stale caches and old backends.
const parseUploadedAt = (value: string | number) =>
typeof value === 'string' ? parseISO(value) : new Date(value);
// Group photos by date
const groupedPhotos = useMemo(() => {
const groups = new Map<string, Photo[]>();
photos.forEach(photo => {
const date = parseISO(photo.uploaded_at);
const date = parseUploadedAt(photo.uploaded_at);
let groupKey: string;
switch (grouping) {
@@ -76,7 +83,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
return Array.from(groups.entries())
.map(([date, photos]) => ({
date,
label: photos[0] ? format(parseISO(photos[0].uploaded_at), grouping === 'month' ? 'MMMM yyyy' : grouping === 'week' ? "'Week of' MMM d, yyyy" : 'EEEE, MMMM d, yyyy') : date,
label: photos[0] ? format(parseUploadedAt(photos[0].uploaded_at), grouping === 'month' ? 'MMMM yyyy' : grouping === 'week' ? "'Week of' MMM d, yyyy" : 'EEEE, MMMM d, yyyy') : date,
photos: photos.sort((a, b) => new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime())
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());