Files
picpeak/backend
261e243070 fix(archives): take the restored category from the manifest (#1240) (stable) (#1243)
* fix(archives): take the restored category from the manifest (#1240) (stable)

Stable twin of #1240. The reporter hit this on 3.46.7 — stable — with 596
photos restored and 0 categories, so this is the branch the bug was actually
found on.

Stable carries the manifest on both sides already: archiveService selects
`photo_categories.name as category_name` and serialises it, and the restore
route builds manifestByFilename. It just never read the category out of it,
deriving one from the ZIP's first path segment instead. Archives store photos
as they sit on disk, so an event whose photos live in the gallery root
produces a flat zip, no category resolves, and every photo comes back with
category_id null — silently, behind a 200.

Carries the whole of #1240, not a subset: the manifest-first resolution and
the shared resolveCategoryId from Marian's commit, plus the follow-up that
makes the manifest authoritative when it says "no category" — an entry with a
null category_name is a photo that was genuinely uncategorized, and falling
through to the directory contradicted the record being restored from. That
matters because the directory is not a category: entry names are the storage
key minus events/active/{slug}, so a real archive yields `individual/` and
`collages/`, and reading the first segment invents categories with those
names.

Re-verified on stable rather than assumed: all four tests pass here, and three
of them fail against stable's current route, with the legacy no-manifest
fallback passing either way. The two changed files are byte-identical to main.

Co-authored-by: Marian <[email protected]>

* fix(archives): keep the stable twin to stable's schema, and close two category holes

External review on #1243 caught that this twin was ported wrong and that the
category resolver has two holes the main PR shares.

PORTED WRONG. I took main's whole adminArchives.js rather than applying the
category change to stable's, which dragged in main-only face cleanup:
photo_faces and event_people have migrations on main and none on stable, so
every permanent archive deletion would have thrown a missing-table error —
after the ZIP was already unlinked, leaving the event archived with its
archive gone and a 500 back. Rebuilt from stable's file with only the category
change; the diff against stable is now the fix and nothing else.

GLOBAL CATEGORIES WERE CLONED. Seeded categories (Ceremony, Reception) have
event_id NULL, so an event-only lookup missed them and created a second row —
and is_global defaults to TRUE, so that duplicate then appeared in every other
event's category list. The lookup now uses the same visibility rule the photo
routes use (own rows OR global), and anything it does create is explicitly
is_global false.

ORIGINAL-FILENAME ARCHIVES MATCHED NOTHING. With
general_use_original_filenames_for_downloads on at archive time, archiveService
names each ZIP entry after the original filename while the manifest stays keyed
by photos.filename — so the lookup missed every entry and those archives lost
categories exactly as before the fix. The manifest is now indexed by
original_filename as well, without letting it shadow a real filename key.

7 tests, three of them new; each new one fails against the un-fixed route and
the legacy no-manifest fallback passes throughout.

* fix(archives): sanitized original names and deterministic category scope

Round 2 of external review on #1243.

The original_filename index used the raw column, but archiveService runs the
name through sanitizeForZipEntry() before writing the entry — so an original
containing a slash or control byte was emitted under a different name than the
manifest records, and the lookup missed it. Both spellings are indexed now,
using the same helper the writer uses.

Not total, and the comment says so: uniquifyZipNames() appends `_1` when two
photos in one event share an original name, and that suffix cannot be
reconstructed from the manifest. Those fall through to the directory exactly
as they did before this fix — no worse, just not better. Closing it needs the
emitted name recorded at archive time, which is a writer change and a new
archive format.

The category lookup used one OR-query with .first(). An event-scoped category
and a global one may share a name — the category API permits it — so the
engine picked whichever, and a photo could be silently reassigned to the
global row, losing event-local settings like allow_downloads. Two queries now,
event-scoped first: the event's own row is the more specific answer.

9 tests, two new; both fail against the un-fixed route.

* fix(archives): don't adopt another event's legacy row, don't guess an alias

Round 3 of external review on #1243.

The global fallback matched on is_global alone. The very bug fixed here left
rows behind on upgraded instances — event-owned AND is_global true, because
the column defaults true — so restoring event B could adopt event A's
leftover, tying B's photos to a category that disappears when A is deleted.
The fallback now requires event_id IS NULL: genuinely global, not merely
flagged.

The original-filename alias map collapsed rows that share a basename.
archiveService treats `individual/IMG.jpg` and `collages/IMG.jpg` as distinct
paths and suffixes neither, so both manifest rows claimed one alias and
whichever won handed the other photo someone else's category. An alias claimed
by more than one row is now dropped and logged, so those photos fall back to
the directory: an unresolved category is recoverable, a confidently wrong one
is not.

11 tests, two new; both fail against the un-fixed route.

* fix(archives): make the manifest lookup order-independent and collision-safe

Two bugs found by an external review round, both in the manifest index.

The canonical map silently kept the last row for a duplicated
photos.filename. That column is not unique within an event — s3AutoImporter
takes path.basename(entry.key) and dedupes by path, so two imported files in
different subfolders both land as IMG_1234.jpg with different paths. At
restore both ZIP entries reduce to the same basename, so one photo got the
other's category. Contested names are dropped now, like ambiguous aliases
already were.

The alias pass could also evict a canonical key: when one row's
original_filename equalled another row's filename, the collision was marked
ambiguous and the sweep deleted the canonical entry. The comment two lines
above says a real filename key is authoritative and must never be
overwritten — the code did the opposite, and which way it went depended on
manifest iteration order, since the archive query has no ORDER BY.

Split into two passes so canonical names are claimed first and aliases only
fill names no canonical row wanted.

* fix(archives): treat a canonical/alias name clash as ambiguous, resolve categories lazily

Round-2 findings, one of which corrects my own round-1 fix.

Round 1 made a canonical filename outrank any alias. That is the wrong
tiebreak: when photo A's filename equals photo B's original_filename, which
file the ZIP actually emitted under that name depends on whether
original-filename archiving was on at archive time — with it ON the entry is
B's, with it OFF it is A's — and the manifest does not record the mode.
Preferring either silently mislabels the other half of the time, so the name
is dropped and both fall through to the directory. What the two-pass split
still buys is determinism: the archive query has no ORDER BY, so this used to
be a coin flip between dropping the name and overwriting it.

Categories are resolved inside the !existingPhoto branch. resolveCategoryId
find-or-CREATES, and archiveEvent retains photo rows, so restoring an archive
whose rows still exist created a category from the stale manifest name that
nothing then used — renaming a category while its event was archived left the
old name behind as an empty duplicate.

Not fixed: two event-scoped categories may share a display name with distinct
slugs, and the .first() lookup then picks either row, so manifest entries from
both collapse onto one id and can inherit the wrong allow_downloads. Detecting
it is easy; resolving it correctly needs a stable category identifier in the
manifest, which is a writer change and an archive-format bump.

* fix(archives): make a duplicate category name deterministic, and log it

Round-2 finding. Two event-scoped categories may share a display name when
their slugs differ, and the .first() lookup then picked one arbitrarily —
manifest entries for both collapsed onto a single id and half the photos
inherited the wrong per-category settings, allow_downloads above all.

Fixing it properly needs a stable category identifier in the manifest: a
writer change, an archive-format bump, and no help at all for archives
already written. Not worth building before knowing it happens. So the
collision is surfaced instead — a warning naming the category and the row
count — and the tiebreak is made deterministic (lowest id) so at least a
re-run lands the same way twice.

If this never fires in real logs, the format change was not worth making. If
it does, this is the evidence for it.

---------

Co-authored-by: Paul Nothaft <[email protected]>
Co-authored-by: Marian <[email protected]>
2026-09-01 08:17:48 +02:00
..