Commit Graph

1940 Commits

Author SHA1 Message Date
Paul Nothaft 7102687ee8 fix(events): delete stored objects when cascading an event delete (#1245)
* fix(events): delete stored objects when cascading an event delete

Stable twin of #1051. Deleting an event removed its database rows but left
every stored file behind: deleteEventCascade() cleaned up with fs.rm over
{STORAGE_PATH}/events/{active,archived}/{slug}, and on an S3-compatible
backend those paths don't exist locally — the call succeeds against nothing
and the real objects stay in the bucket, unreferenced by any row, invisible
in the UI, and billed every month.

Measured on a v3.45.16 install against Cloudflare R2, deleting one
403-photo event: bucket object count 5,400 before and 5,400 after, while
referenced rows dropped from 3,425 to 2,746.

Keys are collected BEFORE the transaction removes the photo rows — once
they are gone nothing records which objects belonged to the event, and only
a full-bucket audit against the whole database could find them again — and
deleted AFTER the commit, so a rolled-back delete can never destroy files
for an event that still exists.

Includes photo.watermark_path and event.archive_path, both storage-backed
and both previously fs.unlink-only. event.hero_logo_path is deliberately
excluded: multer writes logos to local disk with diskStorage regardless of
backend, so they are never bucket objects.

Reference/external photos are left alone — resolvePhotoStorageKey returns
null for them and PicPeak does not own those bytes.

This branch carries the higher priority of the pair: unlike main, stable's
deleteEventCascade never calls getStorage() at all, and the leak costs real
money for every month it goes unfixed.

Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>

* fix(events): sweep the Download All cache, and delete objects concurrently

Both from an external review round on #1051.

The pre-built "Download All" zip (events.download_zip_path) lives under
events/active/{slug}/.download-cache/. On local disk the recursive fs.rm
already covered it, which is exactly why it was easy to miss — on S3 that
prefix is not a directory, nothing covered it, and it is gallery-sized.
downloadZipService exposes a cleanup() documented as "used on event
deletion" that the cascade never called.

download_jobs (main's #173) does not exist on this branch, so the
per-job archives main also sweeps have no counterpart here.

Deletes now run through a bounded pool instead of one await per key: a
400-photo gallery owns well over a thousand objects, and that many
sequential DeleteObject round trips runs to minutes — long enough for a
proxy to time the request out AFTER the commit, leaving the event deleted
and the sweep half-finished.

* fix(events): never delete a derivative another gallery still uses

Round-2 findings from the external reviewer on #1051, ported.

Canonical thumbnail/hero/preview keys are not event-scoped: the basename is
the photo's filename, and filenames are not unique across events. A legacy
gallery can share a canonical derivative with a photo in another event, and
deleting it here blanked a surviving gallery's tile. Derived keys are now
checked against photos outside this event and anything still referenced is
left alone; if the check itself fails, every derivative is kept — an orphan
costs storage, a deleted derivative costs someone else's gallery. Originals
need no check, their keys embed the slug.

Also cancel any in-flight or debounced Download All build before snapshotting
paths, via downloadZipService.cleanup() — the service's own entry point for
event deletion. A builder that started before the delete would otherwise
upload a gallery-sized zip after the sweep and write its path onto a row that
no longer exists.

* revert(events): drop the Download All build cancellation

It broke CI on this branch: the backend job went from ~2 minutes to
exceeding its 10-minute budget, twice, reproducibly.

downloadZipService.cleanup() reaches getStorage() through _cleanup(), and in
a suite where the S3 backend is configured but unreachable every cascade
delete then pays the adapter's retry backoff. The full suite passes locally
against SQLite, which is why this only showed up in CI.

The race it addressed is real but narrow — a builder that started before the
delete uploads its zip after the sweep and writes the path onto a row that
no longer exists, orphaning one object. That is a cheaper problem than an
unrunnable test suite, so it goes back to being a documented follow-up
rather than shipping behind a timeout.

The shared-derivative guard from the same review round stays: that one
prevented deleting a surviving gallery's thumbnail.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:16:51 +02:00
Paul Nothaft 5b69e3ec4c fix(auth): treat zxcvbn suggestions as advice, not blocking errors (#1247)
Stable twin of #1050. passwordValidation.js is byte-identical between the
branches, so this is the same change verbatim.

validatePassword() appended zxcvbn's feedback.suggestions to the errors
array unconditionally, and validity is errors.length === 0 — so any
password that merely earned a suggestion was rejected even when it
satisfied every configured rule. The effective policy was stricter than
the configured complexity level and invisible to the admin.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:16:33 +02:00
Paul Nothaft eebca9900b fix(auth): treat zxcvbn suggestions as advice, not blocking errors (#1247)
Stable twin of #1050. passwordValidation.js is byte-identical between the
branches, so this is the same change verbatim.

validatePassword() appended zxcvbn's feedback.suggestions to the errors
array unconditionally, and validity is errors.length === 0 — so any
password that merely earned a suggestion was rejected even when it
satisfied every configured rule. The effective policy was stricter than
the configured complexity level and invisible to the admin.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:05:57 +02:00
Paul Nothaft c05faa50d9 chore(stable): release 3.46.7 (#1221)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.7
2026-08-29 03:25:38 +02:00
Paul Nothaft 15c844db06 fix(admin): the "Uncategorized" photo filter returns every photo (#1211) (#1215)
Stable twin of the same fix on main.

The dropdown rendered as `value="0"` and adminPhotos.js:1001 skips '0', so no
category condition was applied and the whole event came back. The branch that
does the work sits four lines below, keyed on the literal 'uncategorized' that
nothing was sending.

Silent by nature — a full list reads as 'nothing to narrow' rather than 'the
filter did not run' — which is why it survived this long.

The reporter in #1209 is on 3.46.4, so this is the branch that reaches them.

Tests both ends of the contract, since the bug was the pairing rather than
either half.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:05:21 +02:00
Paul Nothaft c685a3e931 chore(stable): release 3.46.6 (#1207)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.6
2026-08-28 04:00:46 +02:00
Paul Nothaft 74ff236b51 fix(images): fence the capture-date backfill on the file it read (#1201) (#1205)
Stable twin of #1204.

The capture-date backfill committed its result keyed on the row id alone. It
snapshots every candidate up front, then walks them one at a time reading
originals off S3 or a NAS mount — a pass that can run for many minutes.

replacePhoto, reachable from the replace_by_name upload path, swaps a NEW file
under an existing row and rewrites path/filename. A replacement landing inside
that window carries no date of its own, so captured_at was still NULL, the
whereNull guard passed, and the previous file's EXIF date was written onto the
new photo. Silent: nothing errored, the run reported it as a success, and the
gallery just sorted that photo to the wrong place.

Fenced on path and filename as well as the id, so a replaced row matches zero
rows and is skipped. The candidate query already selects both columns, so no
query change. Knex renders a null value in the object form as `is null` on both
the pg and sqlite3 clients, so a row with a NULL path still matches itself.

Those skipped candidates are now counted rather than dropped. replacePhoto is
not the only writer of path/filename — eventRenameService rewrites both on an
event rename, which is not a content change — and another writer filling
captured_at first lands in the same place. Without a counter they fell out of
the run's arithmetic entirely: success + noExif + failed no longer added up to
the count the operator was shown when they started the job.

The card shows the count only when it is non-zero, and states what is known —
changed by something else, not updated — rather than promising a retry: for the
already-dated case there is nothing to retry, and the Missing Capture Date
figure above is what says whether work is left. Locale coverage: en, de, fr, sl,
with the defaultValue carrying the rest.

Regression test: a replacement landing mid-run leaves captured_at NULL and is
not counted as updated. Verified to fail against the unfenced code on this
branch.
2026-08-27 08:44:32 +02:00
Paul Nothaft 292dd4fa09 chore(stable): release 3.46.5 (#1193)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.5
2026-08-26 21:44:17 +02:00
Paul Nothaft 5559cd333d fix(images): respect EXIF orientation in thumbnails, heroes, previews and watermarks (#1185) (#1202)
Stable twin of #1194.

generateThumbnail, generateHeroImage and generatePreviewImage went straight
from sharp(imagePath) to .resize(), so a photo whose Orientation tag is not 1 —
routine for portrait shots on bodies that tag rather than rotate the sensor
data — was resized from the raw frame and came out sideways. The same pipelines
then call .withMetadata(false), stripping the tag from the output, so nothing
downstream could correct it either. The download path already had this right,
which is why the same photo looked correct on download and rotated in the
gallery.

watermarkService had it too, and it is the one a guest actually sees:
gallery.js serves photos.watermark_path ahead of the original when branding
watermarking is on. Two details there — metadata() is read from a separate
unrotated handle, because .rotate() does not change what it reports and every
use of those numbers is positioning; and the composite offsets are floored,
because getPositionCoordinates returns fractional pixels, sharp rejects them,
and applyWatermark catches its own error and silently returns the image
unwatermarked.

The rotate is unconditional in the thumbnail and hero generators — neither
passes `animated: true`, so both already flatten a multi-frame source and
guarding there would protect an animation that was being discarded anyway.
generatePreviewImage keeps the guard, since it genuinely does preserve
animation.

photos.width/height were stored from sharp's metadata, which reports pixels as
STORED, not displayed. For orientation 5-8 those are swapped, so a portrait
photo landed in the database as landscape and masonry sized its tile with the
wrong aspect ratio on top of the image being unrotated. A shared
orientedDimensions() helper now does the conversion at all eight image ingest
sites. The video path is deliberately untouched: its dimensions come from
ffprobe, where EXIF orientation does not apply.

Existing rows keep their pre-rotation dimensions until reprocessed; the backfill
for those is #1199 on main and is not ported here yet.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 21:13:08 +02:00
Paul Nothaft ac7ef266dc fix(admin): make "Storage used" report storage used (#1164) (#1177)
* fix(admin): make "Storage used" report storage used (#1164)

Stable twin of #1170.

The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which in reference mode live on external storage and have no relationship to
the disk PicPeak runs on. The reporter's tile read ~80 GB against 21 GB of
real usage. Worse than the label: the same number drove the soft-limit warning
bar and, via /storage/info, the recommended soft limit — so a reference-mode
install got a disk-capacity recommendation computed from bytes that are not on
the disk.

- new localStorageUsage service walks the storage root and reports the total
  plus a breakdown. Walking rather than summing DB columns is the point:
  thumbnail/preview/hero rows record a key and never a byte count, and orphans
  from a deleted event or an interrupted import are real bytes.
- the external media root is excluded when it sits inside the storage root.
  Its compose default is <storage>/external-media, where the NAS is
  bind-mounted — a plain directory, not a symlink — so walking it would put
  every referenced original back into a figure whose purpose is to leave them
  out. Symlinks are not followed either.
- .download-cache gets its own line: it lives inside the event directory, so
  the naive rule files a multi-GB zip as photography.
- concurrent cold-cache callers share one walk; the dashboard, /storage/info
  and the sidebar are routinely requested together.
- S3 installs keep the catalogued figure and the walk is skipped before it
  runs, since the objects are in the bucket and STORAGE_PATH holds only
  incidental local files.
- an absent measurement reads as "unavailable" and a partial one is marked
  `+` across the dashboard, analytics, sidebar and status tab — a floor
  silently compared against a soft limit reads as "safely under".

Verified on this branch: 11 new service tests, dashboardScope updated for the
changed contract, full suite leaves the same 5 pre-existing failures as
origin/stable. Frontend 20 files / 104 tests, tsc clean.

* fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164)

External review found both of these on this branch.

Both were reported as `storage_measurement: 'catalog'`, so a failed local walk
made the dashboard claim the objects live in S3. They are different things —
one is a fact about the install, the other is a fault — and there is now an
`unavailable` state for the second.

The analytics percentage could reach the billions. `safeSoftLimit` fell back to
`storageUsed || 1`, and on S3 that is null → 1, while the figure beside it came
from `catalogedBytes`. An editor or viewer holds `analytics.view` but not
`settings.view`, so `/storage/info` 403s for them and `storageInfo` is
undefined — which is exactly when that fallback fires. It now falls back to the
measured figure, and suppresses the percentage entirely when there is no real
limit rather than dividing usage by itself and always reading 100%.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:14:48 +02:00
Paul Nothaft 9ffbe2f98f fix(previews): preserve alpha and animation in the preview tier (#1176)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

Stable twin of #1169.

The lightbox read preview_url, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to url, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

slideshow_url is the same /preview/:id URL, watermark query included, and has
been emitted unconditionally for images since #1015. Preferring it fixes every
existing install with no migration and no admin action.

Two other surfaces bypass PhotoLightbox entirely and had the same bug:

- premium galleries build their own slides with `src: photo.url`. Fixing that
  also required carrying the photo id on the slide, because the download
  handler recovered the photo by matching slide.src against photo.url — a
  derivative src would have made Download a silent no-op.
- the Story layout rendered the full original as its GRID TILE, at
  object-cover in a small card, and its hero rendered one as a full-bleed
  background when hero_url exists for exactly that. Cards now use the preview
  tier (not the thumbnail: thumbnail_fit is seeded to 'cover', so a thumbnail
  would be cropped a second time and reframe every photo) and only load once
  within 200px of the viewport, since every card mounts at page load.

GIF, APNG and PNG keep the original: generatePreviewImage encodes JPEG, which
has neither a second frame nor an alpha channel. The backend fix that removes
this list is the next commit in this stack.

Divergence from the main twin: no responsive `?w=` tiers. #1095 is main-only,
so `lightboxImageUrl` here selects a URL and nothing more. It lives in
`imageTiers.ts` under the same path main uses, so that backporting #1095 later
merges into this file rather than landing beside it.

Verified on this branch: 8 new tests; frontend suite 21 files / 113 tests,
tsc clean.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review. Same two fixes as the main twin.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing.

Needed one extra piece here that main already had: generateHeroImage on this
branch ignores outputBasename and always derives the key from the source
basename, so two events referencing the same NAS filename would clobber each
other's hero. It now honours the option, matching generateThumbnail and
generatePreviewImage.

The format bypass trusted mime_type, which is not trustworthy: migration 039
backfilled every pre-existing photo to image/jpeg regardless of what it was,
and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

* fix(previews): preserve alpha and animation in the preview tier

Stable twin of #1171. Stacked on the #1166 twin, whose format bypass this
removes.

generatePreviewImage encoded JPEG unconditionally. JPEG has no alpha channel
and no second frame, so a transparent PNG came back flattened onto a solid
background and an animated GIF came back as its first frame — for every
consumer of this tier, not just the lightbox. It was only invisible by default
because the lightbox served originals.

Sources with alpha, or more than one page, are now encoded as WebP, which
carries both and is still far smaller than the original. Ordinary photos stay
JPEG.

- the output extension matches what was written. A PNG source previously
  produced `preview_foo.png` holding JPEG bytes; harmless while the route
  hard-coded image/jpeg, wrong once the encoding varies. Existing keys keep
  working — they are still JPEG and still served as such.
- the preview route derives Content-Type from the key. With nosniff set,
  mislabelling would show a broken image rather than being silently corrected.
  The watermark branch re-encodes to JPEG and now says so.

The frontend guess-by-MIME goes away entirely, including the case it could
never get right: a still and an animated WebP declare the same type.

Divergence from the main twin: no width-tier case. The responsive `?w=`
renditions (#1095) are main-only, so this branch has a single canonical
preview per photo.

Verified on this branch: 5 new backend tests against real Sharp output;
frontend 21 files / 114 tests; full backend suite leaves the same 5
pre-existing failures as origin/stable.

* fix(previews): retire the legacy preview keys, and stop mislabelling watermarked ones

External review. Same two defects as the main twin.

Legacy keys collide with the new naming. The old generator kept the SOURCE
basename verbatim while always writing JPEG, so a `.webp` upload produced
`previews/preview_shot.webp` holding a JPEG. The claim that pre-existing keys
have no .webp suffix was simply wrong. The route now derives Content-Type from
the key and the response carries nosniff, so every photo uploaded as WebP
would have rendered as a broken image in the lightbox. Legacy `.png` keys are
wrong the other way: flattened JPEGs of what may have been transparent
sources, which isPreviewValid would have let stand forever.

Migration 178 clears photos.preview_path outright — all of it, not just the
suspicious extensions, because a `.jpg` key can equally be a flattened
rendition and nothing in the key says so. Previews regenerate lazily on next
view under the new encoder.

The watermark branch mislabelled its output. applyWatermark PRESERVES the
source format on this branch too (watermarkService.js: png stays png, webp
stays webp), and its input is the preview — so the output already matches the
key the header was derived from. Forcing image/jpeg mislabelled every
watermarked WebP preview, and nosniff means the browser would not correct it.

Numbered 178, not 176: this stack does not carry the external-media
migrations, but that stack takes 176 and 177 on this same branch, and two
files sharing a numeric prefix would be confusing even though both would run.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:14:42 +02:00
Paul Nothaft 75facb4d67 fix(gallery): stop the lightbox loading originals to display a photo (#1166) (#1175)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

Stable twin of #1169.

The lightbox read preview_url, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to url, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

slideshow_url is the same /preview/:id URL, watermark query included, and has
been emitted unconditionally for images since #1015. Preferring it fixes every
existing install with no migration and no admin action.

Two other surfaces bypass PhotoLightbox entirely and had the same bug:

- premium galleries build their own slides with `src: photo.url`. Fixing that
  also required carrying the photo id on the slide, because the download
  handler recovered the photo by matching slide.src against photo.url — a
  derivative src would have made Download a silent no-op.
- the Story layout rendered the full original as its GRID TILE, at
  object-cover in a small card, and its hero rendered one as a full-bleed
  background when hero_url exists for exactly that. Cards now use the preview
  tier (not the thumbnail: thumbnail_fit is seeded to 'cover', so a thumbnail
  would be cropped a second time and reframe every photo) and only load once
  within 200px of the viewport, since every card mounts at page load.

GIF, APNG and PNG keep the original: generatePreviewImage encodes JPEG, which
has neither a second frame nor an alpha channel. The backend fix that removes
this list is the next commit in this stack.

Divergence from the main twin: no responsive `?w=` tiers. #1095 is main-only,
so `lightboxImageUrl` here selects a URL and nothing more. It lives in
`imageTiers.ts` under the same path main uses, so that backporting #1095 later
merges into this file rather than landing beside it.

Verified on this branch: 8 new tests; frontend suite 21 files / 113 tests,
tsc clean.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review. Same two fixes as the main twin.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing.

Needed one extra piece here that main already had: generateHeroImage on this
branch ignores outputBasename and always derives the key from the source
basename, so two events referencing the same NAS filename would clobber each
other's hero. It now honours the option, matching generateThumbnail and
generatePreviewImage.

The format bypass trusted mime_type, which is not trustworthy: migration 039
backfilled every pre-existing photo to image/jpeg regardless of what it was,
and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

* test(gallery): the hero fixture follows the root-relative relpath contract (#1166)

Same fix as the main twin: external_relpath has been resolved from
EXTERNAL_MEDIA_ROOT rather than from event.external_path since #1163 landed,
and this fixture still carried the base-relative form, so the two tests stopped
resolving the moment that stack merged. Production was never affected.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:08:50 +02:00
Paul Nothaft 58ccecc304 fix(admin): move the maintenance sweeps' run state into the database (#1181) (#1188)
Stable twin of #1184. Both photo sweeps tracked whether they were running in a
module-level variable, which is invisible to every other replica: a status poll
routed to an idle replica reports isRunning false while another is mid-run, and
the next POST starts a second pass over the whole library.

Migration 179 adds one row per job, claimed with a conditional UPDATE whose
affected-row count is the answer. The lease is fenced on a per-claim token so a
runner superseded by a stale takeover cannot renew a claim it has lost or
release one it no longer owns; renewal runs on a timer spanning the claim
through release, since one hung NAS read can outlast the stale window inside a
single iteration. maintenance_jobs is excluded from .picpeak archives.

Gated on settings.edit / settings.view rather than main's system.manage, which
does not exist on this branch — they are what settings.edit was later split
into, so both branches let the same people through.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:08:14 +02:00
Paul Nothaft 7f0ed23ea4 fix(external-media): record captured_at on import and add a backfill (stable) (#1183)
* fix(external-media): record captured_at on import and add a backfill (#1172)

Stable twin of #1179.

External media never went through photoProcessor, so captured_at stayed NULL
for every externally imported photo. The gallery's "Date Taken" sort then
degraded into import order through its own COALESCE fallback — a library
imported in two batches showed the first days of a trip after the last ones.

Ported whole:
- adminExternalMedia.js reads the capture date at import, off the file it has
  already opened for the dimensions. Best-effort, like the dimensions.
- A backfill endpoint for photos imported before this, so existing installs
  can fix historical rows rather than only new imports. Managed originals go
  through resolvePhotoStorageKey + withLocalCopy so S3 installs work; archived
  events are excluded because archiving deletes their originals; the run flag
  is claimed before the candidate query so two POSTs cannot both start.
- gallery.js carries photos.id as a tiebreaker on all three sorts. A bulk
  import writes hundreds of rows inside the same second, so uploaded_at ties
  are the normal case and the grid reshuffled between page loads.

One deliberate difference from main: the backfill is gated on settings.edit /
settings.view rather than system.manage / system.view, which do not exist on
this branch. They are what settings.edit was later split into, and main's
migration 175 projects every settings.edit holder forward onto system.manage,
so both branches let exactly the same people through.

* fix(external-media): gate the status card on the permission the button needs (#1172)

The built-in admin role holds settings.view but not settings.edit
(056_add_role_permissions_table.js:63), and StatusTab renders its card and
enabled button purely on a successful status payload. Gating the status
endpoint on settings.view therefore showed every admin a Backfill button whose
every click 403s with no error surfaced.

* fix(gallery): make the Date Taken sort correct on SQLite (#1172)

Same defect as the main twin: photos.captured_at holds three storage classes on
SQLite — an epoch-millisecond integer from managed uploads (photoProcessor.js:441
hands knex a Date), ISO text from external imports and the backfill, and null
falling through to uploaded_at's 'YYYY-MM-DD HH:MM:SS' text. SQLite sorts
INTEGER before TEXT unconditionally, so a 2027 capture came back before a 2020
one, and within the text values the 'T' separator outranked the space.

Normalised in the ORDER BY; Postgres keeps the plain COALESCE, its column being
a real timestamp. Regression tests drive the real gallery route on real SQLite.

* fix(gallery): normalise epoch-integer uploaded_at too, and stop polling a 403 (#1172)

Both follow-ups from the main twin's review, ported.

uploaded_at is not always text on SQLite: a .picpeak restore can carry epoch
milliseconds in from an install that stored them that way, and the fallback
branch read it with substr(), comparing '1830297600000' against
'2020-01-01 00:00:00' as text. Both columns now get the integer/real branch.

The status card also polled every ten seconds regardless of permission. On this
branch that hits every built-in admin — they hold settings.view but not
settings.edit — so each would have had a 403 and a logged denial every ten
seconds for a panel they were never shown.

* style: quote convention in the capture-sort test (#1172)

* fix(capture-dates): skip watcher-imported videos, and make the status counts consistent (#1172)

All three follow-ups from the main twin, ported: the three-marker video filter
(fileWatcher sets type/mime but not media_type, so those rows sat in the
backlog forever), the single-aggregate status counts (two queries could report
a negative backlog mid-import), and the card's render gated on settings.edit as
well as the cached payload.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:04:18 +02:00
Paul Nothaft 2b1c3588ae fix(external-media): store external paths from the media root (#1163) (#1174)
* fix(external-media): store external paths from the media root (#1163)

Stable twin of #1168. Stacked on the #1162 twin, which supplies
deleteDuplicatePhotos.

Importing a second folder into an event silently invalidated every photo
already in it. external_relpath was stored relative to events.external_path,
and every import overwrites that column, so the older rows were rebased onto
the new folder. Nothing errored and the grid still rendered — thumbnails are
written to local storage during the import while the base path is still
correct — so only the things that need the original broke. The reporter had
7547 of 8004 rows pointing into the void.

- external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is
  self-describing and nothing an admin does to the event can move it.
- migration 177 folds each event's base into its rows. Where the current
  resolution is missing it walks up for an ancestor holding a file of the same
  name AND the size the import recorded — existence alone would let a deleted
  file adopt an unrelated namesake and serve the wrong original. Rows it
  cannot place keep resolving where they resolve today, and the probe is
  skipped entirely when the mount is unreachable.
- probing is read-only and runs first; the rewrites and the marker commit
  together, so an interrupted fold cannot be folded twice.
- rewrites are staged through a per-row parking value, because a final path
  can equal another row's current one; and migration 177 re-throws without the
  driver's error code, which run-migrations-safe would otherwise read as
  "schema already exists".
- the fold also runs after a .picpeak restore, since knex_migrations is
  excluded from the archive, and a failure there is reported rather than
  presented as a clean restore.
- drops the duplicate-leaf-segment guess in photoResolver, which papered over
  this same double-prefixing.

Divergence from the main twin: no face-scan requeue reordering. Face
recognition is main-only, so the hazard of queueing rows against unconverted
paths does not exist on this branch — in picpeakImportService or in
restoreService.

Verified on this branch: 23 new tests pass, and the four suites carrying
base-relative fixtures were updated. Full suite leaves the same 5 pre-existing
failures as origin/stable, unchanged.

* fix(external-media): the fold's staging value must be storable on Postgres (#1163)

External review found this on this branch first; it was on both.

The two-pass rewrite parks each row on a temporary value, and that value was
written with a leading NUL. SQLite stores NUL in TEXT without complaint;
Postgres rejects it outright — "invalid byte sequence for encoding UTF8: 0x00"
— so migration 177 rolled back on exactly the installs that need the two-pass
repair, and only on the engine most of them run. Restores hit the same wall
and reported the conversion as failed.

The prefix is ordinary text now. Adds a gated Postgres test alongside the
existing picpeakRestorePg one, because a SQLite-only suite structurally cannot
catch this class: restoring the NUL makes exactly the two-pass repair case
fail with that error, and nothing else.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:00:05 +02:00
Paul Nothaft e9fcf4960e fix(external-media): one row per external file per event (#1162) (#1173)
* fix(external-media): one row per external file per event (#1162)

Stable twin of #1167.

Two overlapping import-external runs against the same event inserted every
file twice. The route checked for an existing external_relpath and then
inserted, with an fs.stat and a sharp().metadata() read sitting in between — a
window wide enough for both runs to see "not there". A reporter's event held
8004 rows for 6012 distinct paths. Nothing at the storage layer stopped it:
migration 041 created only a NON-unique (event_id, source_origin) index.

- migration 176 removes the existing duplicates and adds a partial unique
  index on (event_id, external_relpath), verified against the catalog
  afterwards — a failed CREATE INDEX raises 23505 on Postgres, which
  run-migrations-safe treats as "schema already exists" and would record as
  applied on an install that never got the index.
- dependent rows are removed explicitly rather than by cascade: PicPeak never
  sets `PRAGMA foreign_keys = ON`, so on SQLite the declared CASCADE is inert
  and a bare delete strands feedback and access-log rows. Guest feedback moves
  to the survivor instead of being discarded, keyed on guest identity the way
  feedbackService defines it, and the survivor's denormalized counters are
  recomputed.
- the route treats a unique violation as a skip, so a writer this process
  cannot see converges instead of duplicating, and a second import while one
  is running gets a 409.
- a .picpeak taken before migration 176 carries exactly these duplicates, and
  suspending FK enforcement does not suspend a unique index — so the restore
  drops the index for the load and rebuilds it after running the same dedupe.

Divergences from the main twin, both because the feature is absent here:
faces (no faceProcessor, so no purgePhotoFaces reconciliation — the rows are
still deleted so nothing dangles), admin marks, transfer membership, and
photos.view_count/download_count. The service guards each on hasTable /
hasColumn, so those branches simply do not fire.

Verified on this branch: 36 new tests pass; full suite leaves the same 5
pre-existing failures as origin/stable, unchanged.

* fix(external-media): invalidate the download zip when duplicates are removed (#1162)

External review. Same fix as the main twin.

The pre-built "download everything" archive still contained the duplicate rows
the dedupe had just deleted, so guests kept receiving them. Every ordinary
photo-deletion path calls downloadZipService.invalidate for exactly this
reason.

The columns are cleared rather than the service being called: that service
carries debounce timers and a regeneration queue, which a migration should not
start. getZipInfo already treats a cleared record as a cache miss and rebuilds
on the next request. The stale object is left in storage, as elsewhere.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:55:17 +02:00
Paul Nothaft f83d144f28 chore(stable): release 3.46.4 (#1159)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.4
2026-08-24 20:34:25 +02:00
Paul Nothaft b62cd2c290 fix(gallery): a guest's own hidden feedback is hidden from them too (#1150) (#1157)
Stable twin of #1153.

Everything in the system treats a hidden row as absent, but the per-viewer is_liked heart read the row without looking at is_hidden — so a like the photographer had hidden still showed as liked on a photo whose like_count was zero.

Making that agree exposes the second half: the duplicate check behind like/favorite toggling did not skip hidden rows either, so the now-empty heart, when clicked, found the hidden row and toggled it OFF. Skipping hidden rows there makes the click create a fresh, visible row.

Also carried from review: the per-guest caps, /my-feedback and getEventFeedbackSummary no longer count hidden rows, and unhiding collapses the guest's replacement — skipped when there is no stable identity, since that fallback was 'guest_identifier IS NULL', i.e. other visitors' rows.

Not carried: the my_color_label badge (colour labels are #1044) and the clearScope / singleValueScope visibility fix, neither of which exists on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:09:41 +02:00
Paul Nothaft eaa8b41ba3 fix(gallery): guest filters respect show_feedback_to_guests (#1044) (#1156)
Stable twin of #1147, filter half only.

Every filter token on /photos is an OR of two halves: what THIS viewer marked, and what ANYONE marked. The fields built from the second half are gated on show_feedback_to_guests; the filter was not, so with the setting off ?filter=liked still returned exactly the photos other people liked — the membership instead of the count, one token at a time.

The half it left standing was also the wrong half: it read guest_identifier from the guest_id query parameter, which never matched anything, and accepting a caller-supplied identifier was a way back through the gate. Resolved from the request now, hidden rows excluded to match what the viewer can see.

Not carried: the color: token and the photo_admin_marks concurrent-write fix — colour labels and admin marks are not on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:08:23 +02:00
Paul Nothaft d46397d92a fix(gallery): no Logout button on galleries that don't require a password (#1149) (#1154)
Stable twin of #1152. The reporter is on v3.46.1, so this branch is where the bug was actually seen.

showLogout was hard-coded true, so a gallery with no password showed a Logout button, and clicking it stranded the visitor on the loading skeleton — GalleryPage's auto-login is a one-shot latch that never re-fires.

The button is gated at both call sites, including the full-page layouts which render it on the callback rather than a flag. accessLevel and viaCustomer now come from /auth/session instead of per-tab sessionStorage, which silently downgraded a PIN-client session in a second tab. The public-gallery branch shows a reason and a Retry once auto-login has run and failed, instead of a skeleton that never stops.

Carries the full main fix including viaCustomer, even though reveal mode does not exist here, so the branches do not drift.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:07:05 +02:00
Paul Nothaft e46260ad07 fix(scripts): regenerate-thumbnails resolves external sources through ensureThumbnail (#1148) (#1155)
Stable twin of #1151.

The script here carried the identical defect: it computed `storage/events/active/<photo.path>` and fs.access'd it, which does not exist for external or reference rows. #1129 already landed on this branch, so the route was fixed and the script was the remaining half.

Resolution goes through ensureThumbnail, which stable already exports with the external branch intact. Also carried: videos skipped on every marker, skip-vs-generate asked from isThumbnailValid, and a nonzero exit when a photo could not be built.

Not carried: the responsive-tier backfill — THUMBNAIL_WIDTHS and ensureThumbnailAtWidth are #1095/#1109 and do not exist on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:30 +02:00
Paul Nothaft e9fadd2ef4 chore(stable): release 3.46.3 (#1143)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.3
2026-08-23 20:23:29 +02:00
Paul Nothaft d977e3e296 fix(gallery): give masonry tiles their real shape back (#1130, #1131)
Two independent causes of the same symptom — an aspect-ratio layout that does
not lay anything out.

gallery-premium discarded the tile height MasonryPhotoAlbum computed from
photos.width/height and set height:auto on both card and image, so the rendered
shape came from whatever rendition was served. With thumbnail_fit seeded 'cover'
every rendition is square, so the masonry drew identical squares.

The bundled CSS templates pinned images to a fixed pixel height, which beats the
.h-full utility six of the seven layouts rely on. Elegant Dark is seeded
is_default, so that was the out-of-the-box result for any layout other than
grid/timeline.

Migrations 052/053 corrected for fresh installs; 175 repairs the rows already
seeded. The repair is whitespace-tolerant because sanitizeCSS strips newlines
from any template ever saved through the editor, matches the height property
with a lookbehind so line-height/max-height are untouched, handles grouped
selectors and skips nested rules.

Stable twin of #1135.
2026-08-22 21:36:51 +02:00
Paul Nothaft da44f1947b fix(gallery): a missing file must not take the backend down (#1128)
LocalFsStorage.get() returns an fs.createReadStream, which is lazy: it resolves
immediately and opens the file on a later tick, so an ENOENT arrives after the
await returned and outside the route's try/catch. An unhandled 'error' event is
a process-level throw Express cannot catch — the backend exits and every gallery
goes blank until the container restarts.

gallery.js had ten .pipe(res) calls and zero error handlers.

pipeStreamToResponse attaches the missing handler: a vanished source becomes a
404 (410 for a prepared zip), anything else a 500, and a source that dies
mid-response destroys the connection rather than rewriting a status already on
the wire. Headers staged for the file are cleared first — Express does not
overwrite an existing Content-Type, and a surviving Cache-Control would let a
transient 404 be cached as a broken tile for up to an hour. It also releases the
source when a client hangs up.

Applied to all eight streaming responses, not just the thumbnail route.

Stable twin of #1133, reduced: the tier-race half does not apply here because
ensureThumbnailAtWidth does not exist on this branch.
2026-08-22 21:36:43 +02:00
Paul Nothaft dc9e3cdc5e fix(thumbnails): regenerate external photos, and stop destroying good ones (#1129)
POST /admin/thumbnails/regenerate resolved every source as
storage/events/active/<photo.path> and fs.access'd it. External and reference
rows are not there — their originals live under events.external_path — so every
one failed the check and was counted as an error, while the UI reported success
because the response is sent before the background loop starts.

It now goes through ensureThumbnail, which resolves both source kinds and writes
thumbnail_path back itself. Nulling thumbnail_path stops it short-circuiting on
isThumbnailValid, which matters because the old thumbnail is normally still
readable at exactly the moment someone presses regenerate.

And the half that destroys data: generateThumbnail deleted the target BEFORE
sharp had opened the source, and again in its catch. A source that could not be
read — a NAS mount that blipped — left the previous rendition gone and the
database pointing at it. Across a bulk regenerate that is the whole gallery.
Neither delete was needed: put stages to a temp file and renames atomically, and
put is the last statement in the try so no partial object can exist.

Also: videos filtered out, and the superseded rendition removed only when the
storage key actually moved, compared through the same canonicalisation the
backends apply.

Stable twin of #1134.
2026-08-22 21:36:19 +02:00
Paul Nothaft 7598e20f55 chore(stable): release 3.46.2 (#1121)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.2
2026-08-21 20:30:47 +02:00
Paul Nothaft 32db1c8052 fix(ui): stop iOS Safari zooming in on 14px form fields (#1114)
Closes #1105.

iOS Safari zooms the whole page in when a focused form control computes to
under 16px, and it does not zoom back out. Unlocking a gallery is a
client-side transition rather than a document navigation, so the zoom the
password field triggers carries straight into the gallery: the layout pans
horizontally and the header actions sit off-screen until the visitor
pinch-zooms out by hand. A real page load would have reset it.

`.input` and `.input-themed` are `text-sm`, so the field is 14px on every
phone, and GalleryPage inverts the breakpoint on top of that
(`text-sm sm:text-base` — 14px below 640px, where iOS zooms, and 16px above
it, where it never does).

Keyed to the POINTER, not a width. The zoom depends on the computed font size
and a touch device, never on how wide the viewport is — and a phone in
landscape is 667-956 CSS px, above any width you could call "phone". Measured
on the admin login page, which has no `sm:` override:

  main   portrait 390x844    14px    zooms
  main   landscape 844x390   14px    zooms
  main   iPad 820x1180       14px    zooms
  fixed  all three           16px
  fixed  desktop (mouse)     14px    unchanged, no zoom off touch

One media query rather than flipping each call site. `.input` alone backs 334
`<Input>` usages, but there are also ~440 raw inputs, selects and textareas
carrying their own `text-sm`, and Tailwind utilities sit in a later layer than
@layer components — so a fix at the component definition misses most controls
and any new `text-sm` silently reintroduces the bug.

The `:not()` on each selector is load-bearing, not decoration: it buys the
specificity to beat a utility class. Measured in a browser —

  input.text-sm     16px   (0,2,1 beats .text-sm)
  select.text-sm    14px   (0,0,1 loses)
  textarea.text-sm  14px   (0,0,1 loses)

24 selects and textareas in the tree carry `text-sm`, so the bare form would
have left them zooming. Checkbox and radio stay excluded so font-size never
sizes their box.

max(16px, 1em, 1rem) is a FLOOR, not a size. A flat 16px would make controls
that are already bigger smaller: Typography -> Large sets --font-size-base to
18px on body, so anything inheriting it would be clamped down and the setting
quietly ignored. Each term covers a case the others miss:

  normal (body 16)       16px      Large theme (body 18)    18px
  Small theme (body 14)  16px      browser default 20px     20px

The viewport meta is deliberately left alone: `maximum-scale=1` would suppress
the zoom by disabling pinch-to-zoom for everyone.
2026-08-21 19:25:33 +02:00
Paul Nothaft 9833237d37 chore(stable): release 3.46.1 (#1082)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.1
2026-08-19 20:29:01 +02:00
Paul Nothaft 83290a0f1a chore(security): ignore unfixed CVEs in Trivy, override deepmerge-ts (#1085)
Stable twin of #1083, scoped to what exists on this branch.

docker-build.yml — set ignore-unfixed on both Trivy steps. Stable has
the backend and frontend legs only (no aio, no ml), so two steps here
against four on main. Base-image CVEs with no released fix are not
actionable: the Dockerfiles already run `apt-get upgrade -y` behind a
CACHEBUST, so a fix lands in the next build automatically. Reporting
them buries anything someone can actually act on.

backend — deepmerge-ts <8.0.0 has a stack-exhaustion advisory
(CVE-2026-40345, high) reached via mailparser -> html-to-text, which
pins ^7.1.5 so npm cannot get there alone. Stable carries the same
mailparser ^3.9.9 and the same 3-high exposure as main. Not reachable
in our code: html-to-text only feeds deepmerge-ts its options object,
never parsed email content. npm audit on this branch goes 3 high -> 0.

The ml/Dockerfile half of #1083 has no counterpart here — the face
sidecar does not exist on stable, so there is nothing to drift.

Verified on stable itself rather than assuming main's results carry:
npm audit 3 high -> 0, html-to-text exercised end-to-end through
simpleParser, and jest at 1577 passed. The 5 failing suites (20 tests)
fail identically on clean origin/stable with these changes stashed.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 13:56:10 +02:00
Paul Nothaft 6df42ab22c fix(preview): generate lightbox previews for external/reference photos (#1078) (#1080)
* fix(preview): generate lightbox previews for external/reference photos (#1078)

Stable twin of the main-line fix. ensurePreviewImage() resolved its source
only via resolvePhotoStorageKey(), which returns null for external/reference
photos by design — those live on a media mount outside the managed storage
tree. The null went straight into withLocalCopy(), which throws, so the
preview route fell back to redirecting at the full-size original. Galleries
whose photos are all external got no benefit from the preview tier (#492):
guests paid 5-12 MB on every lightbox open.

Add the external branch ensureThumbnail() already has: resolve via
resolvePhotoFilePath() and feed the mount path to generatePreviewImage()
directly, with an ext<id>_ output basename.

generatePreviewImage() on this branch hardcoded path.basename(imagePath) and
ignored options.outputBasename, so it needs the same one-line honouring that
generateThumbnail() already does — without it two events referencing the same
NAS basename collide on one preview key.

Also return null rather than throwing for a row with no source_origin in a
reference-mode event, whose mode falls back to the event's.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(preview): select the columns the external branch needs on bulk regenerate

POST /api/admin/thumbnails/regenerate-previews selected only id, event_id,
path, media_type, mime_type and preview_path, so photo.source_origin was
undefined by the time ensurePreviewImage branched on it. Every external row in
a reference gallery took the managed path, resolvePhotoStorageKey returned null
for it, and the endpoint reported success while generating nothing.

Add source_origin, external_relpath and filename to the select, plus a
source-inspection test pinning the caller contract and a service-level test
showing a column-starved row is indistinguishable from a managed one.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 10:17:48 +02:00
Paul Nothaft 45ffe64b7c fix(storage): write business documents under STORAGE_PATH, not the cwd (#1072)
Stable twin of #1070.

persistDocPdf, the invoice sending and reminder writers, both contract
signature writers and persistSignatureImage built their targets from
`path.join(process.cwd(), 'storage', 'business-docs', ...)` and never
consulted STORAGE_PATH. Both compose files pin STORAGE_PATH=/app/storage
and the image's WORKDIR is /app, so on a stock deployment the two name
the same directory and nothing looked wrong. Point STORAGE_PATH anywhere
else and quotes, invoices, Mahnungen, contracts and signature images
land outside the configured storage root: missed by the backup walker,
invisible to storage accounting, and gone when the container is
replaced.

assertContractPdfPath moves with them. On this branch the writers and
the guard are wrong together, so contract downloads currently work —
migrating the writers alone would have introduced PATH_OUTSIDE_STORAGE
on every newly generated contract. The guard now resolves through
getStoragePath() like the writers, and keeps the legacy cwd root so
contracts written before this still resolve; their absolute paths are
in the database.

Also on the shared resolver: the custom PDF font lookup (a font under
STORAGE_PATH/fonts was never found, and the document silently fell back
to the built-in face) and the two backup diagnostics, which otherwise
inspect a different root than the backup walker when STORAGE_PATH is
unset.

No migration needed — the persisted path is stored absolute.

Verified on this branch, not inferred from main: the new test is 6/6,
and contract/quote/invoice/pdf/safePath suites are 213/213 both before
and after the change.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:14:49 +02:00
Paul Nothaft 84eab88801 test(e2e): read the admin JWT from the cookie, not the login body (#1073)
Stable twin of #1071.

Three specs acquire an admin token with `const body = await res.json();
return body.token`. On this branch too the admin login sets the JWT as
the httpOnly `admin_token` cookie and responds with `res.json({ user })`
— verified in auth.js on stable, not assumed from main — so the token is
undefined and each spec fails at its first assertion, before exercising
anything it was written to cover.

Cookie and Authorization: Bearer are interchangeable server-side, so the
helpers read the value back out of the context cookie jar and keep
threading it as a Bearer. Every downstream call is unchanged.

Verification is weaker than the main twin's, deliberately: the three
spec files are byte-identical to the ones measured there (0 passed /
6 failed before, 3 passed / 3 failed after, against a live stack), and
they compile and enumerate on this branch. Standing up a full stable
compose stack to re-measure test-only changes was not worth it — say the
word if you want that done before merge.

The remaining failures are UI staleness, not auth, and are not addressed
here. No CI workflow runs tests/e2e on this branch either, which is why
this rotted unnoticed.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:14:15 +02:00
Paul Nothaft 10d5cf54a5 chore(stable): release 3.46.0 (#1060)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.46.0
2026-08-16 20:22:19 +02:00
Paul Nothaft 376311cb90 fix(pdf): RFC 6266-encode Content-Disposition on quote/invoice PDFs (#1024) (#1062)
Stable backport of #1055 (main: 3a11e6eb). Change content is byte-identical
to the main twin; cherry-picked clean, no resolutions needed.

The six quote/invoice PDF endpoints interpolated buildPdfFilename()'s result
straight into `inline; filename="${filename}"`. That result deliberately
preserves non-ASCII (it doubles as the PDF's internal Title metadata), and
HTTP header values are latin1, so a customer label reaching the header
directly failed in one of two ways:

  - U+0080-U+00FF (ä ö ü ß — every German umlaut): no throw. The raw byte
    goes out and the client reads back a mangled name. Silent corruption.
  - above U+00FF (Polish ł, Czech ř, Turkish ş, €, Cyrillic, CJK, emoji):
    Node's setHeader rejects it with ERR_INVALID_CHAR. The throw lands
    after the PDF buffer is already rendered, so the request 500s.

This corrects the issue's diagnosis: it reported umlauts as the 500 case,
but umlauts are inside latin1 and mangle rather than throw.

Route all six through buildContentDisposition(), which emits an ASCII
fallback plus the RFC 5987 filename*=UTF-8'' form. Also stops sanitiseSegment
splitting surrogate pairs at its 80-unit cap — a dangling high surrogate makes
encodeURIComponent throw URIError inside the helper, reaching the same 500 a
different way (found by external review on the main twin).

Verified on this branch: 14/14 in the new suite, 151/151 across the nine
surrounding pdf/filename/quote/invoice suites, lint clean.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:52:39 +02:00
Paul Nothaft 88fa3c5297 fix(storage): add S3 client timeouts so a dropped connection can't wedge uploads (#1049) (#1054)
Stable backport of #1049 (main: 3600231d). Change content is byte-identical
to the main twin.

S3StorageAdapter built its S3Client with no requestHandler timeouts, and the
SDK's defaults wait indefinitely. When a connection is dropped silently (no
FIN/RST — NAT/LB idle reaps, transient faults), the in-flight request hangs
forever and every subsequent storage operation queues behind it process-wide;
only a restart recovers. _retryOperation never ran because the promise it
wraps never settled.

Configure connectionTimeout (120s) and socketTimeout (60s) on the request
handler, overridable via STORAGE_S3_CONNECTION_TIMEOUT /
STORAGE_S3_SOCKET_TIMEOUT, and add TimeoutError to the retryable list so the
existing backoff engages.

socketTimeout rather than requestTimeout: the latter is a total-duration cap
that would kill legitimate large uploads and only warns without
throwOnRequestTimeout. Both values are deliberately generous — connectionTimeout
covers time queuing for a socket from the agent pool (maxSockets 50), so a
short value expires while merely waiting in line and breaks reads.

Reported against v3.45.16 on Cloudflare R2: wedged roughly every 40 minutes
with serial uploads, every 15-20 with 4 parallel uploaders.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:23:59 +02:00
Paul Nothaft 980378a17b feat(backup): open sqlite → pg .picpeak restore as the supported upgrade direction (#1041) (#1059)
Stable backport of #1043 (main: 8809564a).

sqlite → pg restore is allowed for anyone holding backup.restore, from the
upload UI and the CLI alike, gated by the manifest-direction rule in
validateManifest. pg → sqlite stays refused, with an error naming the
supported direction. allowEngineSwitch is removed rather than kept alongside:
one gate, no way to drive the refused direction.

Two resolutions were needed against stable rather than a clean cherry-pick,
both from known main/stable divergences:

  - replaceAllTables has no roleSnapshot parameter on this branch, so the call
    keeps stable's 4-arg signature while taking the derived { crossEngine }.
  - resyncSequences was guarded by `if (allowEngineSwitch)`, which this change
    removes — leaving an undefined reference. It now runs unconditionally,
    matching main. That also closes a stable-only gap: a same-engine pg → pg
    restore previously left identity sequences stale, so the next natural
    insert collided on the primary key.

Also exports resyncSequences (the function already existed here, main already
exports it) so the cross-engine suite can drive the post-restore fixup.

Verified on this branch: all four picpeak suites green on SQLite, and 20/20
against a real Postgres 15 with the PICPEAK_PG_TEST_URL-gated cases executing.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:20:57 +02:00
Paul Nothaft 0a999795cc ci(tests): run the gated real-Postgres .picpeak cases in the backend job (#1058)
Stable backport of #1056 (main: 18b1e0f6). Change content is byte-identical
to the main twin.

The .picpeak restore suites gate their Postgres cases behind
PICPEAK_PG_TEST_URL and describe.skip themselves out when it is unset. The
variable is set in no workflow, so those cases have never run in CI.

On this branch the effect lands together with the #1041 backport, which brings
picpeakCrossEngine.test.js and its three real-Postgres stored-value cases —
stable has no picpeakRestorePg.test.js, so before that PR this wires up a
service nothing reads yet. Merging it first keeps the two twins mirroring
their main counterparts one-for-one instead of folding both into one PR.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:17:17 +02:00
Paul Nothaft ed4e32c4df chore(stable): release 3.45.16 (#1047)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.45.16
2026-08-13 20:58:32 +02:00
Paul Nothaft 9003b34c8a fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038) (#1040)
* fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038)

knexfile.js selects its config block by NODE_ENV and the `development` block
defaults to sqlite3. The image never set NODE_ENV, so every deployment that
doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD.

It stayed invisible because wait-for-db.sh is shell: it reads DB_HOST directly,
connects to Postgres, creates the database and logs "PostgreSQL is up" in the
same container where the Node process then writes to a SQLite file. Migrations
go through src/database/db.js → the same knexfile, so they also ran against
SQLite, leaving the provisioned Postgres database empty.

Setting the default alone would be unsafe: an affected install would flip to
Postgres on its next image pull and come up against an EMPTY database, which
reads as total data loss. So this adds a guard that runs before migrations
touch anything:

  - logs the resolved engine + target at boot
  - refuses to start when pointed at a virgin Postgres while a populated
    SQLite file exists, naming the file and the .picpeak export path for
    moving the data, with PICPEAK_ALLOW_EMPTY_PG=true as the escape hatch
  - warns but boots when Postgres settings are present yet SQLite is in use

Compose files already set NODE_ENV explicitly, so compose users are unaffected.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): stay on SQLite instead of blocking, and add a migration path (#1038)

Reworks the guard after walking through what an existing install actually
experiences on its next image pull.

Blocking was the wrong trade. An operator who had unknowingly been running on
SQLite (because the image left NODE_ENV unset) would have pulled the fix and
got a CrashLoopBackOff: data safe, galleries offline, for something they did
not do. Now the boot RESOLVES the engine before migrations run and stays on
whichever one holds the data:

  - Postgres configured but holding no galleries, while a populated SQLite file
    exists → keep serving from SQLite, print what happened and how to migrate.
  - once Postgres holds the data, the next restart switches over on its own.
  - an explicit DATABASE_CLIENT is always honoured.

Keyed on Postgres holding DATA, not on it having tables: a stray
`run-migrations` against the empty database creates every table, which would
otherwise blind the check.

Adds scripts/migrate-sqlite-to-postgres.js, which reuses the .picpeak
export/import services rather than hand-rolling a cross-engine copy. Two
additions were needed for the SQLite → Postgres direction, both opt-in and
CLI-only so the upload/restore UI is untouched:

  - `allowEngineSwitch` relaxes the importer's same-engine guard
  - cross-engine row coercion: SQLite has no real date or boolean types, so its
    rows carry epoch numbers where Postgres wants a timestamp and 0/1 where it
    wants a boolean, both of which Postgres rejects outright. Driven by the
    TARGET schema, never guessed from the value.

DELTA FROM THE BETA PR: this branch's import service has no resyncSequences()
— that landed on main only. Without it a cross-engine load leaves Postgres
identity sequences at 1 and the next insert collides on the primary key, so
the function is backported here and called ONLY on the cross-engine path.
Same-engine restores through the UI keep their current behaviour exactly.

Verified end to end on this branch against a real PostgreSQL 15: a seeded
SQLite install migrated across with booleans, timestamps and foreign keys
intact, and the next INSERT got id 2 rather than colliding.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close four review findings on the SQLite fallback + migration (#1038)

External review (codex) found four issues, all confirmed against the code and
fixed here. Two of them could have cost data.

1. The engine resolver was reachable only through wait-for-db.sh. A Kubernetes
   manifest that sets `command`/`args`, or a plain `docker run … node
   server.js`, bypasses the entrypoint — exactly the deployment styles this fix
   targets. With NODE_ENV now baked into the image, such an install would have
   resolved to Postgres and come up against an empty database while its SQLite
   data sat there unseen. server.js now resolves the engine itself, before
   anything requires knexfile, via the same script the entrypoint uses.
   Verified by running `node server.js` directly against an install with
   stranded SQLite data: it logs the banner and serves SQLite.

2. Cross-engine loads double-encoded JSON. SQLite has no json type, so its json
   columns are TEXT holding JSON; the export dumps that as a string and
   serialiseJsonColumns stringified it again, storing `true` as the scalar
   string "true". app_settings.setting_value is json on every install, so this
   reshaped every migrated setting. The text is decoded before serialisation
   now — verified against a real Postgres: json_typeof(setting_value) is
   `boolean`, matching a native install exactly.

3. The migration could silently miss concurrent writes. If the backend keeps
   serving, rows written after the export never reach Postgres and vanish from
   view once the engine switches. The script now fingerprints the SQLite tables
   whose loss would be noticed, checks for drift BEFORE loading Postgres (so a
   detected race leaves the target untouched) and again after, and refuses with
   the exact rows that moved. It also says plainly to stop the backend first.

4. The child phases shared stdout with winston. Outside production, and
   whenever LOG_TO_CONSOLE=true, createPicpeak's own log line was concatenated
   with the archive path and the migration failed on a bogus filename. Payloads
   travel through a result file now; verified with LOG_TO_CONSOLE=true.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 2 — six more data-safety findings (#1038)

1. The engine choice is now PINNED once the data is in Postgres. Previously the
   boot decided from "does Postgres hold galleries", so an operator who later
   deleted every gallery would be sent back to the stale pre-migration SQLite
   file while their settings, admins and CRM data stayed in Postgres. The
   migration writes a marker next to the database file (and retires the file
   itself by renaming it); the marker wins over any probe.

2. The migration refused to overwrite Postgres only when it held GALLERIES. A
   target with admins, customers, invoices or projects but no galleries was
   wiped without --force. Both the source and target checks now look for user
   data across the tables that are empty on a fresh install.

3. Same bug in the other direction: an install with no galleries but real
   admins/settings/customers was refused a migration it was entitled to.

4. Drift detection covered four tables and only count/max(id), so an in-place
   UPDATE (event edit, password change) or a write to any other table passed
   unnoticed. It now fingerprints every table the export carries, including
   max(updated_at). It still is not a substitute for stopping the backend, and
   the script says so rather than implying a guarantee.

5. probeSqliteData() treated an unreadable or corrupt file as "no data", which
   would have switched the install to an empty Postgres — the very failure this
   module exists to prevent. It fails closed now and stays on SQLite so the real
   error surfaces.

6. The "you are leaving SQLite data behind" warning was unreachable: setting
   DATABASE_CLIENT skipped the probes, so the branch that produces it never had
   the inputs. Postgres and SQLite are both probed whenever Postgres is the
   engine in play.

Also: the final verification compares row counts for EVERY table rather than
just galleries, and flags only a shortfall — the import legitimately adds an
app_settings row (setSessionsValidAfter) that made the strict equality fail on
a first real run.

Verified against a real PostgreSQL 15 end to end, including: the marker keeps
an install on Postgres after every gallery is deleted; removing the marker and
restoring the file rolls back to SQLite as documented.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 3 — occupancy, bootstrap admin, secrets in /tmp (#1038)

1. Both engine probes judged occupancy by GALLERIES alone. An install whose
   galleries were all deleted, but which still has admins, customers or
   accounting records, was treated as empty: on the SQLite side that meant
   booting the empty Postgres and appearing to lose everything; on the Postgres
   side it meant diverting a live install to a stale SQLite file. Both now look
   across the tables that are empty on a fresh install, matching the migration
   script.

2. The migration ran migrate-schema BEFORE checking the target, and migration
   001 seeds a bootstrap admin when ADMIN_PASSWORD is set (common on legacy
   installs). The occupancy check then saw that admin and refused, pushing the
   operator towards --force against a genuinely empty database. The target is
   read first now.

3. probeSqliteData()'s warning went through the app logger, which writes to
   STDOUT when LOG_TO_CONSOLE=true — and the resolver's stdout is the protocol
   channel wait-for-db.sh captures, so DATABASE_CLIENT could have been set to a
   JSON log line. Diagnostics take an injected sink (stderr in the resolver),
   and the shell now validates the value it captured instead of trusting it.

4. The .picpeak archive holds password hashes, SMTP credentials and API keys in
   plaintext, and was only removed on the fully-successful path — any drift or
   import failure left it in /tmp. Every exit path removes it now.

5. A database-only migration still hauled every business-doc and upload through
   /tmp and back into the same volume. createPicpeak takes includeFiles:false
   for this path; rows move, files stay where they already are.

Verified against a real PostgreSQL 15: a gallery-less install with only an admin
account now stays on SQLite and migrates successfully with ADMIN_PASSWORD set;
the resolver emits exactly one token on stdout with LOG_TO_CONSOLE=true and a
corrupt database; a drift failure leaves Postgres untouched and no archive
behind.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): pin the boot to SQLite while a migration is unfinished (#1038)

Review round 4. A migration that dies after touching Postgres leaves rows
behind — schema creation alone seeds a bootstrap admin when ADMIN_PASSWORD is
set, and a drift or row-count failure can leave a partial load. Since the
occupancy probes were widened in round 3, those rows read as "Postgres is
occupied", so the next restart would switch engines and hide the SQLite data
that is still the database of record.

The script now writes a pin file next to the database BEFORE its first Postgres
write and clears it only on success (after the success marker exists, so no
restart in between can pick the wrong engine). While the pin is present the
resolver stays on SQLite and explains why.

Verified against a real PostgreSQL 15 by reproducing the exact scenario: a
migration failed mid-run with ADMIN_PASSWORD set, leaving one bootstrap admin
in Postgres. With the pin the next boot resolves to sqlite3; with the pin
removed it resolves to pg — the failure this closes. The subsequent successful
re-run clears the pin and the boot moves to Postgres.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 5 — occupancy, path drift, retry, host default (#1038)

1. A seeded bootstrap admin counted as "Postgres is occupied". core/001_init.js
   inserts one whenever ADMIN_PASSWORD is set, so a Postgres that was
   initialised once and never used would have beaten a SQLite file full of real
   galleries — the exact failure the guard exists to prevent, reintroduced by
   widening the probe in round 3. The two sides are deliberately asymmetric now:
   the SQLite probe counts any user data (err towards keeping data visible),
   the Postgres probe ignores rows that schema creation seeds (err towards
   requiring proof of real use).

2. The guard resolved DATABASE_PATH with its own logic while knexfile trimmed
   whitespace and collapsed the legacy duplicated-backend form. A path either
   engine normalised differently meant probing a file nobody uses, concluding
   there was no SQLite data, and booting an empty Postgres. The resolution now
   lives in one module both require.

3. Re-running after a partial migration — the documented recovery — was refused
   unless the operator passed the destructive-sounding --force, because the
   half-written rows read as target data. An unfinished run of this same script
   is now recognised as a safe retry.

4. wait-for-db.sh verified readiness against its own default host (`postgres`)
   while knexfile's production block defaults to `db`. With NODE_ENV now baked
   in, a bare `docker run` without DB_HOST would have passed the readiness check
   against one host and then dialled another. The entrypoint exports the exact
   connection it verified. Compose sets DB_HOST explicitly and is unaffected.

Verified: a Postgres holding only a seeded admin now loses to real SQLite data;
a DATABASE_PATH with surrounding whitespace resolves to the identical file in
both knexfile and the guard.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 6 — explicit-client bypasses, retry scope, cleanup (#1038)

1. An explicit DATABASE_CLIENT bypassed the unfinished-migration pin, because
   decideBootEngine honoured it first. docker-compose sets DATABASE_CLIENT=pg,
   so a failed migration would have restarted on a half-written Postgres on
   exactly the deployments that pin it. Worse in the other direction: with
   DATABASE_CLIENT=sqlite3, a SUCCESSFUL migration renames the source file, so
   the next start created a NEW, empty SQLite database and served that. The pin
   now outranks explicit pg (clearing the marker is the override), explicit
   sqlite3 is left alone since it already points at the data, and the migration
   refuses up front when the deployment pins anything other than pg.

2. The retry allowance was bound to the SQLite file, not to the target. An
   operator who repointed DB_HOST/DB_NAME between attempts could have replaced
   an unrelated populated database without --force. The pin records the target
   and the allowance only applies when it matches.

3. The printed rollback did not roll back: with data on both sides and no
   marker, the resolver still selects Postgres. It now spells out all three
   steps, including DATABASE_CLIENT=sqlite3.

4. A failure inside createPicpeak left a partial archive — plaintext hashes and
   credentials — in the caller-supplied temp dir, which that service
   deliberately does not clean. The export phase removes it on error.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 7 — pin bypass on direct start, real admins (#1038)

1. server.js only ran the engine resolver when DATABASE_CLIENT was unset, so a
   deployment that both bypasses the entrypoint (Kubernetes `command:`) AND
   pins DATABASE_CLIENT=pg never consulted the migration pin — the round-6 fix
   was unreachable on exactly that path, and a failed migration would have
   served a half-populated Postgres. The resolver now also runs whenever a pin
   file exists.

2. Round 5 excluded admin_users from Postgres occupancy to stop a seeded
   bootstrap admin counting as real data. That over-corrected: an install that
   has completed first-run setup but has no galleries yet has exactly one
   user-created row — an admin — so Postgres looked empty and, with a stale
   SQLite file present, the boot would switch away and the admin's credentials
   and configuration would disappear.

   core/001_init.js seeds must_change_password=true; setupService writes false
   once a human completes setup. The FLAG, not the table, distinguishes them,
   and a legacy NULL counts as a real admin.

Verified against a real PostgreSQL 15: a Postgres holding only the seeded row
loses to real SQLite data, the same Postgres wins once setup is completed, and
a server started directly with DATABASE_CLIENT=pg and a pin present comes up on
SQLite with the warning.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 8 — reset admins, CLI config, JSON nulls (#1038)

1. must_change_password is mutable: resetAdminPassword() re-raises it on REAL
   accounts (userManagementService.js:474). Round 7's discriminator therefore
   read a gallery-less Postgres whose only admin had been reset as an untouched
   bootstrap seed — and with a stale SQLite file present, the boot would have
   switched away and hidden those live credentials. The rule is layered now:
   more than one admin, any admin that has logged in, or must_change_password
   false all count as use. Only core/001_init.js's exact leftovers — one admin,
   never logged in, still flagged — read as a seed.

2. The CLI read process.env directly but never loaded the configuration the
   child phases get through knexfile, so running it directly (or via
   `docker exec`, which does not inherit wait-for-db.sh's exports) failed the
   pre-flight checks even with valid settings in backend/.env or
   /run/secrets/db_password. Both sources are loaded up front now.

3. The migration's target check counted a seeded bootstrap admin as user data
   while probePgData classified the identical row as empty, so migrating into a
   previously-initialised-but-unused Postgres demanded --force. Same rule on
   both sides.

4. Cross-engine JSON handling is simpler and no longer lossy. SQLite keeps json
   columns as TEXT holding valid JSON and Postgres accepts JSON text directly,
   so the correct action is to pass them through untouched. Round 1 parsed then
   re-serialised them to undo a double-stringify; that round-tripped the JSON
   literal `null` into SQL NULL, changing data and breaking NOT NULL json
   columns. Not serialising at all fixes both.

Verified against a real PostgreSQL 15: a migrated install now carries
json_typeof = null for a JSON null, object for a nested object, and boolean for
a boolean — matching a native install exactly.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 9 — probe error classes, marker ordering (#1038)

1. probePgData() answered every failure with "Postgres has data". That is right
   for an unreachable server — the app cannot run on it either way, and
   diverting a healthy pg install to a stale SQLite file over a transient blip
   would be worse — but wrong for a server that answers and then fails the
   query, which is what a half-built or damaged schema looks like. That is not
   evidence of data, and reporting it as such booted the empty Postgres and hid
   a populated SQLite file: the exact failure this guard exists to prevent.

   Reachability is now established with SELECT 1 first, so the two cases get
   opposite answers: unreachable → leave the configured engine alone;
   reachable-but-uninspectable → unproven, and the SQLite side wins if it
   actually holds data.

2. The success marker was written after the SQLite file was renamed away. A
   failure in between — a full disk — left the source retired with no marker:
   the next attempt reported "No SQLite database", the in-progress pin stayed,
   and the operator never saw the rollback path. The marker is written first
   and updated with the retired filename once the rename succeeds, so a failure
   at any point leaves everything recoverable.

Verified against a real PostgreSQL 15: a reachable database whose admin_users
table lacks the probed column now resolves to sqlite3 rather than hiding the
data, while an unreachable host still resolves to pg.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): don't fail the migration on empty SQLite-only tables (#1038)

Review round 10. The final verification flagged every source table missing from
Postgres, regardless of whether it held rows — and SQLite-only tables do exist:
initializeDatabase() creates an `events_new` scratch table and, when its legacy
column copy throws, the catch swallows the error and leaves the empty table
behind (db.js:236). The importer correctly skips tables Postgres does not have,
so verification then reported a mismatch AFTER the data had already landed,
exited 1, and left the install pinned to SQLite with no way to finish.

An absent target table only matters if the source actually had rows. Empty ones
are now listed and skipped.

Reproduced both ways against a real PostgreSQL 15 with an events_new table
present: without the fix the run ends in "ROW COUNTS DO NOT MATCH" and leaves
the in-progress pin; with it, the table is reported as skipped, the migration
completes and the pin is released.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): a completed migration overrides an implicit SQLite config (#1038)

Review round 11. The migration allowed the one configuration it should have
worried about most: DATABASE_CLIENT unset AND NODE_ENV not "production", which
resolves to the development block — i.e. sqlite3. That is precisely the state
the affected installs are in, since it is why they ended up on SQLite at all,
so an operator can easily run the migration before fixing it. The script then
renames the source database away, and the next start resolved to the implicit
sqlite3, created a NEW empty database and served it — after reporting success.

The success marker now overrides an IMPLICITLY resolved sqlite3 when Postgres
settings are present, because the marker is durable proof of where the data
actually went. An explicit DATABASE_CLIENT=sqlite3 still wins: that is the
documented rollback.

The script says something rather than refusing — refusing would block exactly
the population this exists for.

Reproduced with NODE_ENV and DATABASE_CLIENT both empty, against a real
PostgreSQL 15: the migration completes, the source is renamed away, and the
next boot resolves to pg with the data intact. Before this it resolved to
sqlite3 and would have served an empty database.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* refactor(db): drop the dead reachability flag in probePgData (#1038)

github-code-quality flagged `if (reachable)` as always true, and it is right:
the unreachable branch returns, so everything below it runs only when the probe
connected. The variable and the conditional were leftovers from a first draft
that used a single catch for both failure classes.

No behaviour change — the two error paths still return opposite answers.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): refuse to choose when both databases hold data (#1038)

Review round 12.

1. An install that ran on PostgreSQL, lost NODE_ENV/DATABASE_CLIENT, and kept
   working on SQLite has REAL data on both sides: old rows in Postgres, newer
   ones in SQLite. The stranded-data rule only protected SQLite when Postgres
   was empty, so pulling this fix would have booted Postgres and hidden every
   gallery created since the switch — the exact failure this PR exists to
   prevent, in a variant I had not considered.

   A completed migration leaves a marker saying which side is current. Without
   one, two populated databases are a conflict: the boot stops and prints both
   targets, the two DATABASE_CLIENT values that resolve it, and the migration
   command that merges them. This is the only deliberate refusal in the change —
   guessing here would hide data AND split subsequent writes across two
   databases.

2. probePgData was handed knexConfig.connection even when knexfile had resolved
   to SQLite (a completed migration whose environment still says sqlite3), so
   node-postgres dialled its own localhost defaults instead of DB_HOST/DB_NAME —
   false "unreachable" diagnostics and a needless delay on every boot. The probe
   target is now built from the environment when the config is not pg.

The conflict is honoured by all three entry points: the resolver exits 3 with an
empty stdout, wait-for-db.sh stops the container, and server.js refuses to start.

Two existing tests asserted that Postgres wins when both sides hold data. They
encoded the pre-conflict assumption and described a state that cannot occur
after a real migration (which always leaves a marker); both now pass the marker.

Found while testing: the resolver's logger shim had no .error, so the conflict
path threw, was swallowed by the fallback, and silently chose Postgres — the
precise outcome this refuses to make. The shim is complete now.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): symmetric bootstrap rule, one resolved Postgres target (#1038)

Review round 13. Both findings are consequences of earlier rounds.

1. The conflict rule added in round 12 counted an untouched SQLite bootstrap
   admin as data. core/001_init.js seeds one whenever ADMIN_PASSWORD is set —
   including into the accidental SQLite database — so a healthy Postgres install
   that had ever started once without NODE_ENV would have had a seeded-only
   SQLite file beside it, been declared a both-populated conflict, and REFUSED
   TO BOOT. The bootstrap discrimination is applied on both sides now; a
   setup-completed or logged-in admin still counts as real use on either.

2. The CLI's child phases inherited whichever knexfile block NODE_ENV selected.
   The development block defaults Postgres to localhost/postgres/photo_sharing,
   production to db/picpeak/picpeak — and this script is explicitly meant to run
   with NODE_ENV unset. With DB_USER/DB_NAME left to defaults it would therefore
   have migrated into `photo_sharing`, after which following the script's own
   advice to set NODE_ENV=production pointed the app at an empty `picpeak`.

   The target is resolved once, with production defaults, and passed explicitly
   to every phase — so the block knexfile happens to pick can no longer decide
   which database the data lands in. The pin and success marker record that same
   resolved identity.

Verified against a real PostgreSQL 15: a live Postgres beside a seeded-only
SQLite file now boots pg rather than refusing, flipping that admin to
setup-completed restores the conflict, and a migration records
localhost:7102/picpeak_r13b as its target rather than a defaulted guess.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): one Postgres identity everywhere; protect the credentials file (#1038)

Review round 14. Three of the six findings were the same defect as round 13's,
surfacing through paths that fix did not cover: the connection used to PROBE or
MIGRATE could differ from the one the application then OPENS, because
knexfile's development block points Postgres at localhost/postgres/photo_sharing
while production uses db/picpeak/picpeak.

1. server.js exported only DATABASE_CLIENT=pg after the resolver decided, so
   knexfile filled in host/user/database from whichever block NODE_ENV selected.
   With SQLite already retired by a migration, that meant opening an empty
   database. The whole connection is pinned now.

2. Two defaults existed for DB_HOST: wait-for-db.sh resolves and exports
   `postgres`, knexfile's production block says `db`. Since the entrypoint
   exports its value, `postgres` is what a running container actually uses — so
   a `docker exec` migration, which inherits neither, has to agree with that,
   not with the default that is only reached when the entrypoint did not run.

3. The migration's Postgres phases inherited an unset NODE_ENV and therefore the
   development block, which ignores DB_SSL entirely — a managed Postgres
   requiring TLS could never be migrated into. The phases run with production
   semantics now.

4. core/001_init.js writes data/ADMIN_CREDENTIALS.txt, and that data directory
   belongs to the SOURCE install. Bootstrapping the Postgres schema replaced the
   operator's real credentials file with ones for a temporary admin the import
   immediately discards. The file is preserved across the phase, including when
   it fails.

5. The boot line described knexConfig, so an install redirected to Postgres by a
   migration marker still logged "Database engine: sqlite (...)", contradicting
   the warning printed one line earlier.

6. On a both-populated conflict resolveBootEngine returns client:null, and both
   migration runners told the operator their data was in "null" and to set
   DATABASE_CLIENT=null. They now present the two real choices.

Verified against a real PostgreSQL 15: a migrated install started directly with
NODE_ENV unset now logs `postgres (localhost:7102/picpeak_r14)` and opens it,
where before it would have gone to the development block's photo_sharing.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* refactor(db): resolve the PostgreSQL target in exactly one place (#1038)

Rounds 13 and 14 both traced back to the same thing, each time through a caller
the previous fix had not covered: three different defaults existed for the same
connection.

  knexfile development : localhost / postgres / photo_sharing
  knexfile production  : db        / picpeak  / picpeak
  wait-for-db.sh       : postgres  / picpeak  / picpeak   (and it EXPORTS them)

So a process that probed or migrated against one could hand over to a process
that opened another. Patching each caller was not converging — the guard, then
the CLI's child phases, then server.js — so this deletes the divergence instead.

`src/utils/pgConnection.js` now owns the resolution and knexfile's development
and production blocks both derive from it, as does the engine guard. Same shape
as the earlier sqlitePath.js extraction, for the same reason.

The database NAME is what made this dangerous: a wrong host or user fails
loudly at connect time, while a wrong name connects fine and presents an empty
installation.

BEHAVIOUR CHANGE: with DATABASE_CLIENT=pg and no DB_* variables, a
non-production environment now resolves to postgres/picpeak/picpeak instead of
localhost/postgres/photo_sharing. Deployments are unaffected — compose sets
these explicitly and wait-for-db.sh exports them — but a local machine running
Postgres bare now needs DB_HOST=localhost DB_USER=postgres DB_NAME=photo_sharing
(or DATABASE_CLIENT=sqlite3, which is what backend/.env already uses). The
failure mode of getting this wrong is a refused connection, not a silently empty
database.

Side effect worth having: DB_SSL is now honoured whatever NODE_ENV says, so the
managed-Postgres case is fixed at the root rather than by forcing production
semantics onto the migration's child phases.

The test block keeps its own photo_sharing_test default — isolation is the point
there.

Verified: every block plus the guard resolve identically from the same
environment; explicit DB_* still wins; production's pool tuning is preserved;
and a full SQLite → PostgreSQL migration with NODE_ENV unset lands in the right
database with JSON shapes intact.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): two more components that guessed the database instead of asking (#1038)

Both found while sweeping for copies of the connection defaults. Checked in
detail first — one of my suspicions about them was wrong.

scripts/set-admin-password.js hand-rolled its own knex config while all four
sibling scripts (reset-admin-password, create-admin, show-admin-credentials,
reset-admin-mfa) use the application's connection. Two consequences:

  - it read DB_CLIENT, a variable nothing else in this codebase sets, so it
    defaulted to Postgres and could not work on a SQLite install at all;
  - it defaulted to database `picpeak_dev`, a name no other component uses.

It now uses `require('../src/database/db')` like its siblings, so it follows
whatever engine the install actually runs on. Timestamps are written as ISO
strings because it reaches SQLite now, where raw Date objects are the documented
landmine.

NOT changed: the script's "all existing sessions have been invalidated" notice
is accurate — auth.js compares token iat against password_changed_at — and it
deliberately leaves must_change_password alone, which is right for an operator
choosing a password rather than being issued one.

routes/adminSystem.js re-derived three things the live connection already knows,
and each could disagree with it:

  - the engine, from DATABASE_CLIENT || 'sqlite3' — so a Postgres install
    without an explicit DATABASE_CLIENT took the SQLite branch;
  - the Postgres database, from DB_NAME || 'picpeak';
  - the SQLite file, from a hardcoded ../../data/photo_sharing.db that ignored
    DATABASE_PATH entirely.

All three now come from db.client.config, with pg_database_size(current_database()).

Verified: set-admin-password works on SQLite (new hash verifies, old rejected)
and still on PostgreSQL; and on a SQLite install with a custom DATABASE_PATH the
size logic reports the real database (1,748,992 bytes) where the old code
reported a different file entirely (1,851,392) — or 0 where that path does not
exist.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): bind the migration marker to its target; fix a phantom table (#1038)

Review round 15.

1. The marker records `host:port/database`, but only its EXISTENCE was checked.
   Repoint DB_NAME or DB_HOST at a different, empty PostgreSQL after migrating
   and the marker would vouch for that one too — booting it, presenting an empty
   installation, and suppressing the SQLite fallback while the real data sits in
   the recorded target and the renamed rollback copy. The marker is compared
   against the current connection now, and a mismatch stops the boot with both
   targets named and the two ways out.

2. `incoming_invoices` is not a table — supplier documents live in
   `inbound_documents` (core migration 124). Both occupancy lists skip tables
   that do not exist, so those records were silently not protecting anything:
   an install whose only remaining data was inbound documents could be switched
   away from, or overwritten without --force. Verified every other name in the
   lists against the live schema at the same time.

Verified: a marker naming picpeak_original with picpeak_mk configured refuses
with exit 3 and prints both; making them agree boots pg.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:20 +02:00
Paul Nothaft de459c701f fix(feedback): persist guest feedback settings, unshadow the guest route (#1030) (#1032)
Enabling Guest Feedback on an event could silently do nothing.

1. `updateEventFeedbackSettings` spread the request body straight into the
   knex UPDATE. The admin event form posts its whole client-side state,
   including three keys that were never columns on event_feedback_settings
   (`enable_rate_limiting`, `rate_limit_window_minutes`,
   `rate_limit_max_requests`), so the write threw and the route answered 500.
   Writable columns are now whitelisted; identity columns and timestamps stay
   server-managed.

2. EventDetailsPage swallowed that 500 in a bare `catch {}` ("Error already
   handled by mutation" — it is a different request), so the admin was left
   looking at "Event updated successfully" while the toggle never persisted.
   The error is surfaced now and the settings query is invalidated on success.

3. gallery.js declared a duplicate `GET /:slug/feedback-settings`. server.js
   mounts galleryRoutes before galleryFeedback, so it shadowed the real
   handler and dropped the per-guest caps (#655) from the guest payload — the
   gallery could never render the favorite/like limits or their counters.

Timestamps are written as ISO strings so they round-trip on both engines.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:16 +02:00
Paul Nothaft 8b6cd3c74f fix(gallery): coerce SQLite 0/1 booleans in the guest surface (#1028) (#1037)
SQLite stores booleans as 0/1, Postgres as true/false. The guest gallery
compared strictly against `true`/`false`, so every flag read backwards on
SQLite installs:

    allow_downloads:    0 !== false → true   (header Download button shown
                                              with downloads disabled)
    allow_user_uploads: 1 === true  → false  (upload button hidden with
                                              uploads enabled)

Worse, all five download guards used `allow_downloads === false`, which never
fires against a stored 0 — so on SQLite "Allow photo downloads = off" was
inert end to end: single photo, download-all, download-selected, download-jobs
and the job-status poll all kept serving, as did the secure-images download
route. Per-category blocking (#640) was ignored for the same reason, the
protection toggles (right-click, devtools, canvas, watermark) reported false
while enabled, overlay_protection was stuck on, and show_feedback_to_guests
leaked feedback with the setting off.

The /info endpoint was already correct — it checks 0/'0' explicitly. The two
payloads had simply drifted. Everything now goes through parseBooleanInput
(utils/parsers.js), which normalises both engines and takes a per-column
default so legacy NULL rows keep their documented behaviour.

Tests run on the SQLite harness, so they assert the real engine values. Every
one of them fails on the unfixed code — the payload assertions return the
inverted value, and the guard assertions never get their 403 (the request
proceeds to serve instead).

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:12 +02:00
Paul Nothaft fb3d0b08b2 fix(events): make event_date/expires_at nullable on SQLite (#1029) (#1036)
Clearing a gallery's expiration failed on every SQLite install with

    SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at

surfacing in the admin UI as "Failed to update event".

Migration 061 added the event_require_event_date / event_require_expiration
settings and dropped the NOT NULL on both columns — but only for Postgres. It
skipped SQLite on the premise that "SQLite doesn't enforce NOT NULL as
strictly", which is untrue, so "never expires" was never reachable there. The
#426 work that allows clearing the expiration on edit therefore never worked
on SQLite either.

Migration 174 finishes 061 for SQLite. Knex implements .alter() on SQLite by
recreating the table; migration 073 already does that on `events`, so the path
is well-trodden. Postgres is skipped — it was handled in 061 and .alter() there
would needlessly rewrite a column that is already correct.

The test asserts against the real engine (the Jest harness runs SQLite) and
reproduces the reporter's exact error without the migration.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:09 +02:00
Paul Nothaft 945e63ae86 chore: ignore all of backend/storage on stable (#1033)
main already ignores `backend/storage/` wholesale; stable only ignored
`backend/storage/business-docs/`. A dev instance writes event photos,
thumbnails and previews into backend/storage/, so `git add -A` on this
branch sweeps 17 runtime artifacts into the commit.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:04 +02:00
Paul Nothaft 93d4ae68f4 chore(stable): release 3.45.15 (#1017)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.45.15
2026-08-10 20:52:00 +02:00
Paul Nothaft 2bdb1204fe fix(slideshow): stop "no crop" fit letterboxing a pre-cropped frame (stable) (#1015) (#1019)
Backport of #1018 to the curated channel; the reporter on #1015 is running v3.45.14.

The slideshow resolved its image as preview_url || hero_url || url. preview_url is only emitted when lightbox_preview_enabled is on (default false), so a default install fell through to hero_url — the 1920x1080 fit:'cover' centre crop built for gallery header banners. object-fit: contain then letterboxed an already-cropped 16:9 frame.

Emits slideshow_url (same aspect-preserved preview tier) unconditionally for image photos; the show prefers it and never falls back to hero_url. preview_url stays gated so the lightbox opt-in is unchanged.
2026-08-10 13:32:24 +02:00
Paul Nothaft cee0a380a6 fix(deps): bump nanoid and js-yaml out of two HIGH advisories (stable) (#1014)
Backport of #1013 to the curated channel. Both are production dependencies of the backend image (npm ci --omit=dev):

- nanoid 3.3.16 -> 3.3.18 (CVE-2026-67213, infinite loop in customAlphabet)
- js-yaml 4.3.0 -> 4.3.1 (GHSA-5p4m-2wfm-xmqj, quadratic CPU in !!omap resolution)

Stable reported no open alerts only because its last Trivy scan ran on 2026-08-04 with v3.45.14, before either advisory was published — the vulnerable versions were present in the lockfile regardless.

Lockfile-only; the existing ^ ranges already permitted both fixes.
2026-08-10 11:00:03 +02:00
Paul Nothaft c01d8d8d2e chore(stable): release 3.45.14 (#990)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.45.14
2026-08-04 21:26:28 +02:00
Paul Nothaft bf9bd76278 fix(security): vet the destination project when linking a deal (stable) (#992)
Backport of #991. stable carried the identical code path and the same missing
guards.

A scoped admin could point a quote or contract at a project they do not own —
the quote/contract create+update paths pass a body-supplied projectId with no
ownership check, and linkDealToProject's lineage guard is skipped when the deal
has no event yet. On an ownerless project this escalated to a read once the
quote converted to an event.

Vetted at the service choke point, ahead of both the null-deal early return and
the customer check. 404 PROJECT_NOT_FOUND throughout. super_admin unaffected.
2026-08-04 16:36:28 +02:00
Paul Nothaft 0fe5792a7d fix(deps): bump ip-address, brace-expansion and postcss for open CVEs (stable) (#988)
Backport of #987. stable carried the same vulnerable versions.

  brace-expansion  5.0.8  -> 5.0.9   CVE-2026-69152 (high)
  ip-address       10.2.0 -> 10.4.0  CVE-2026-69192 (high), CVE-2026-54272,
                                     CVE-2026-69198 (medium) — SSRF and
                                     trust-boundary bypasses
  postcss          8.5.18 -> 8.5.23  CVE-2026-69153 (medium)

Lockfile holds exactly one entry per package, all at or above the fixed
version; the image installs via npm ci --omit=dev.
2026-08-04 14:36:10 +02:00
Paul Nothaft 3f7364be8e chore(stable): release 3.45.13 (#972)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
v3.45.13
2026-08-03 21:26:03 +02:00