invalidateAll() invalidates every event holding a cached zip, and each
invalidate() arms its own debounce timer in the same tick — so they all fired
together and every one of them started building at once. Each build opens its
own storage reads, so flipping a global setting across 25 events was enough to
exhaust the S3 agent pool and stall uploads, thumbnails and gallery reads until
the burst drained. Nothing capped it.
Background rebuilds now run two at a time. The cap is deliberately only on that
path: a foreground generateZip — a guest actually waiting on a download — is
never queued behind a settings-change burst, which would trade one stall for
another.
stop() drains anything parked for a slot, so shutdown cannot hang on a queue
that will never move.
This is the second half of the problem. The first half — an individual build
opening one unbounded read per photo — is the storage-read guard that already
landed for the guest download routes and the job builder; the cached-zip
builder's own copy is still in an open PR.
Relates to issue 1399
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Building the download-all archive opened one storage read per photo and
handed each stream to archiver, which uses them one at a time. Every read
past the one being written parked an S3 socket with a full receive buffer,
and both early exits walked away from all of them: archiver's abort() does
not touch its source streams, and the error path only removed the temp
directory. Nothing else reclaims those sockets either, because the SDK arms
its socket timeout on a 3s delay and clears it as soon as the response
headers land. On a live server 43 of the 50 pooled sockets ended up stuck
for days and photo uploads stopped completing, with nothing logged.
The common way in is an ordinary upload. invalidate() runs on every photo
upload, delete and bulk edit, and it aborted an in-flight build.
Track every open read and destroy them on every exit path, cancel the
in-flight build from invalidate() rather than waiting for the loop to reach
its next version check, and cap reads in flight at 2. A build of 120 photos
peaked at 50 concurrent GETs before, the whole agent pool, which starved
uploads and thumbnails on its own.
Only S3 deployments are affected. Local filesystem installs take the
archive.file() branch and open no sockets.
* fix(gallery): bound and reclaim storage reads in the remaining zip builders
PR 1402 fixes the cached-zip builder. The same unguarded pattern — one storage
read appended per photo, archiver draining one at a time, nothing destroying
the rest — is still present in three other places, two of which a gallery guest
reaches with no admin credentials:
- routes/gallery/downloads.js, download-all and download-selected
- services/downloadJobService.js, the custom-resolution job builder, which is
worse in one respect: its per-photo catch skips a bad source without ever
destroying the stream it had already opened, so every skipped photo leaked a
socket for the life of the process
An unread S3 response body holds its socket open indefinitely — the SDK arms
its socket timeout on a 3s delay and clears it the moment response headers
land, so a fast response never gets one — and archiver's abort() does not touch
its source streams. That is the mechanism behind the incident described in PR
1402: 43 of 50 pooled sockets held with unread bytes, uploads and gallery reads
starved behind them, a process restart the only way out.
utils/archiveStreamGuard.js caps reads in flight at 2 and destroys whatever is
still open on every exit: an error, a failed append, and — for the two guest
routes — the client closing the tab mid-download, which previously left every
appended-but-undrained read parked forever.
Deliberately does not touch downloadZipService.js, so there is no conflict with
1402. Once that lands, its inline equivalent can move onto this helper.
Local-filesystem installs are unaffected either way: they take archiver's
archive.file(path) branch and open no sockets.
Relates to issue 1399
* fix(gallery): survive a cancelled download and a read that dies while queued
Two failures found reviewing the previous commit, both reachable on an
ordinary download.
A client hanging up mid-download aborted the archive, but the append loop's
`break` still fell through to archive.finalize(), which rejects with ABORTED.
The catch then called errorResponse over a response whose ZIP headers had
already gone out, throwing ERR_HTTP_HEADERS_SENT from an async Express 4
handler with nothing to catch it — an unhandled rejection, on a cancelled
download, which can take the process down. Both bulk routes now return without
finalizing, and the catch stays quiet once headers are sent.
A read that errored while still QUEUED behind another was absorbed by the
guard's own error listener. archiver had not attached its source listener yet,
so it never learned the stream had died, and the dead stream stayed in the
queue: the archive hung when it reached it, and in downloadJobService the build
held its concurrency slot with it. The guard now reports such a failure through
onFatalError so the caller aborts the archive and reclaims the rest. Streams the
guard destroyed itself are deliberately not reported — that is the caller's own
teardown, already running.
Relates to issue 1399
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The chunked upload route reads the raw request body and its only client
sends application/octet-stream, but the CSRF gate on /api rejected every
such request with 415 before it reached the route. The endpoint had never
accepted a chunk.
The gate's origin check is the CSRF defence. Its Content-Type list only
has to keep out what a cross-site page can send without a preflight, and
octet-stream is not one of those: a form cannot produce it and fetch()
with it is not CORS-safelisted. Accept it. express.json leaves an
octet-stream body unread, so JSON routes see an empty body as before.
FixesPicPeak/picpeak#1377
* fix(upload): stop buffering a chunk body before anything checks its size
The chunk route drained the whole request into an array and concatenated it
before calling uploadChunk — which is where every check lives. So a 300MB body
sent against an unknown upload id was read in full, cost ~300MB of heap, and
was only then answered with an error. The per-file cap was real but applied
after the damage, and nothing looked at Content-Length at all.
uploadChunk now takes the request stream itself and consumes nothing until the
upload id, the chunk index and the declared Content-Length have all been
checked against the remaining allowance. A body that clears those is streamed
straight to the chunk file under a hard byte cap, so a sender that lies about
its length — or sends none, the Transfer-Encoding: chunked case — is cut off
at the allowance instead of being read to the end.
A Buffer is still accepted, so the existing callers and chunkedUploadSizeCap
tests are untouched. Worth noting the old code silently did nothing when
handed a stream: fs.promises.writeFile accepts an async iterable, so the body
was written while `chunkData.length` was undefined and the cap comparison was
NaN > max, i.e. always false.
Reachable only for an authenticated admin holding photos.upload on an event
they own, and only once the CSRF gate lets application/octet-stream through.
Relates to issue 1403
* fix(upload): harden the chunk stream against abort, retry and cap failures
Three failure paths the streaming rewrite introduced, all found by external
review. None existed in the buffered version: the async iterator it replaced
rejected a dead request on its own, and never opened the chunk file until it
already held the whole body.
- An already-destroyed request hung the call forever. If the client hangs up
while auth and ownership are awaiting the database, pipe() emits neither
`end` nor `error`, so the promise never settled and the write descriptor
stayed open. Checked up front now, alongside `aborted` and a `close` without
`readableEnded` for a body cut short mid-flight.
- A failed re-send destroyed the chunk it was replacing. createWriteStream
truncates on open, so re-sending an index and then failing left
receivedChunks and chunkSizes still claiming the old copy: status reported
100% and completeUpload died on ENOENT. Chunks are staged through a sibling
.part file and renamed only on success.
- Tripping the cap stopped the 413 from reaching the client. `source` is the
IncomingMessage, so destroying it destroyed the socket under the response
and the client saw a connection reset instead of the size-limit JSON. The
read is paused instead, which is all the cap needs.
Relates to issue 1403
* fix(upload): isolate chunk staging per attempt and close before cleanup
Two races found by a second review round, both in the staging logic added by
the previous commit.
- Two in-flight sends of the same chunk index shared one `.part` path, so
whichever renamed first published bytes the other had already truncated. An
acknowledged 10-byte chunk could end up 2 bytes. The staging suffix is now
per-attempt rather than per-index.
- Unlinking the partial file raced the write stream's pending open(). destroy()
does not await it, so the unlink failed with ENOENT and the open then
recreated the `.part` file after cleanup had supposedly finished — reported
reproducible in 121 of 300 immediately-failing streams. Cleanup now waits for
the stream to close.
Relates to issue 1403
* fix(upload): revalidate the per-file cap before publishing a chunk
`allowance` is computed before the body arrives, so a chunk that completed
while this one was still streaming was not counted in it. Two overlapping
0.75MB chunks under a 1MB cap were therefore both accepted, leaving 1.5MB on
disk; enough concurrent streams could go well past the cap before anyone asked
to complete the upload.
The buffered version got this right for free, because it only ever checked
after reading the whole body. The streaming version keeps its pre-read check —
that is what makes a too-large request cheap — and asks again with the current
aggregate before renaming the staging file into place.
Relates to issue 1403
* fix(upload): answer client-caused chunk states with their own status code
An unknown, finished or expired upload id, and a complete call with chunks
missing, were plain Errors with no statusCode, so both routes fell through to
the blanket 500 and logged at error level. All four are the client's mistake:
they read as a backend fault in monitoring and invite a retry that can never
succeed.
They now carry 404, 409, 410 and 400 respectively, and both routes pass a
tagged status through instead of matching on the two they happened to know
about. Only genuinely unexpected errors reach the 500 and the error log.
No client is affected: uploadLargeFile, the only caller of this endpoint
family, still has no callers of its own.
Relates to issue 1403
* fix(upload): retire the connection after an early refusal, clean up a failed publish
Two more from review.
Refusing a body before reading it is the point of the streaming cap, but the
unread bytes are still in flight on a connection the response advertises as
keep-alive. Node does not drain them, so the NEXT request on that socket hangs
until it times out — reproducible with an 8MB body against a 1MB cap. The
error response now sets Connection: close whenever the request was not read to
the end.
A failed rename — ENOSPC, a vanished directory — left the fully written
staging file behind. Staging names are per-attempt, so a client that retries
instead of aborting accumulates one per try until the upload expires. The
partial is removed on that path too.
Relates to issue 1403
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
fix(gallery): keep an admin draft preview out of the guest share-login flow
Making verify-token pass for a draft preview opened a path that did not exist
before it: the gallery bootstrap then called shareLinkLogin, which refuses a
draft AND records a failed login attempt against the caller's IP while doing
it. Five preview opens inside the attempt window therefore locked share-link
logins out for that IP — including for real guests, and including after the
gallery was published.
An admin preview does not need a guest session at all. The admin cookie plus
admin_preview=1 already authorizes every gallery call, which is exactly how
preview works on a published gallery, so the preview path loads the gallery
directly and never touches the login endpoint.
Deliberately not fixed by relaxing shareLinkLogin's draft check: that endpoint
mints a guest token, and a draft should not be handing those out.
Relates to issue 1386
fix(gallery): let an admin preview a draft through its short share URL
/info has honoured admin_preview since issue 868, but two sibling routes on the
short-URL path never did:
- GET /resolve/:identifier filtered drafts out through ACTIVE_EVENT_FILTER
(shareLinkService.js), with no escape for a verified admin.
- GET /:slug/verify-token/:token repeated the same filter inline, so clearing
the first would only have moved the 404 one step later.
With "use short gallery URLs" off the View Gallery link carries the slug,
GalleryPage never calls /resolve, and the preview worked. With it on the link
is the token form, GalleryPage resolves it first, and the draft answered
"Gallery Not Found".
resolveShareIdentifier takes an includeDrafts option, and /resolve reaches for
it only after the published lookup misses AND verifyAdminPreview accepts the
caller — so the published path keeps its single query and an unverified caller
never learns the draft exists. The frontend already sends admin_preview=1
(EventDetailsHeader.tsx:203, forwarded by config/api.ts:81); only the backend
had to change.
GHSA-rh8r's rule is unchanged and now pinned by test: a bare slug lookup still
never returns share_token, draft or not.
Relates to issue 1386
Once an event left `standard` protection, both halves of the video path were
routed through /api/secure-images, and neither half can carry a video.
galleryQueryService emitted the secure template as a video's `url`. The
lightbox drops that straight into a <video> element; nothing substitutes the
`{{token}}` placeholder (the helper that could, secureToken.service.ts, has no
importers), so the request answered 403 "Invalid or expired token". Even with a
valid token it would still have failed — the secure-images route pipes every
byte through secureImageService.processProtectedImage, which calls sharp() and
throws on an mp4. routes/gallery/media.js bounced the JWT route to that same
endpoint before reaching its own video branch, so there was no way through.
Videos now keep the JWT route at every protection level, on both sides. That is
not a new exposure: thumbnails of those same videos have always been served
from it, and a valid gallery token is still required to reach it. Still images
are unaffected and keep bouncing to the secure endpoint.
VideoPlayer had no `error` listener, so all of this rendered as a poster frozen
at "0:00 / 0:00" behind a play button that did nothing — indistinguishable from
a codec the browser cannot decode, which is the other common cause (HEVC/H.265
phone footage plays in Safari and nowhere else). It now surfaces the failure
and names the codec case, since the answer there is to download the file.
Relates to issue 1370
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The restore flow accepted an unvalidated database.backup_file from
the manifest (absolute paths and traversal both worked, and no
containment check enforced the configured backup root), then
interpolated it unescaped into a `sqlite3 .restore '<path>'` command,
letting an attacker-chosen source file replace the live database.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(backend): validate the S3 endpoint host before the restore download
downloadFileFromS3() built an S3StorageAdapter and called .download()
directly, skipping the isHostAllowed() private-IP/DNS-rebinding guard
that testConnection() applies elsewhere — an admin with backup.restore
could point the configured S3 endpoint at an internal/metadata address
for unauthenticated egress via the server.
* fix(backend): pin the restore S3 download to its validated DNS resolution
isHostAllowed() was check-then-connect: the AWS SDK re-resolves the
endpoint hostname independently when it actually connects, so a DNS
rebinding condition between the preflight check and the real
connection could still reach a private/internal address. Reuses the
same pinnedRequestOptions() primitive webhookDeliveryWorker.js already
uses, wired into the S3Client's requestHandler.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Admin password reset generated a ~2^21-entropy password from a small
wordlist instead of the already-available generateSecurePassword(16),
and must_change_password was written on reset but never checked by
any route-blocking logic — a reset user could keep using the old
session/password indefinitely.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(backend): require actor to hold every permission of a role they grant
Any admin with `users.edit` could grant an arbitrary non-super_admin
role — including one carrying far more permissions than they
themselves hold — via PUT /api/admin/users/:id. The role-change path
never called the existing assertActorMayGrant() guard that already
protects role create/edit.
* fix(backend): apply the same role-grant guard to admin invitations
createInvitation() only blocked granting super_admin — the same
users.create-holder-can-invite-into-any-role escalation that
updateAdminUser() was fixed for (GHSA-rv8w-m6mx-7j4q) was still open
via POST /admin/users/invite. Reuses assertActorMayGrant().
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(backend): validate event id before using it in the logo storage filename
The multer filename callback built the stored path directly from
req.params.id with no integer validation, letting a traversal payload
in the route param escape the intended uploads/logos/events/
directory — most directly reachable via a super_admin session, since
requireEventOwnership short-circuits with no DB lookup for that role.
* fix(backend): validate contract id before using it in the signed-PDF storage filename
Same pattern as the event-logo fix (GHSA-9q5j-vqfw-32hr) in a
different file adminContracts.js never touched: multer's filename
callback ran before express-validator's :id check, letting a
traversal payload escape uploads/contracts/signed/.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(backend): validate business-profile logo uploads by content, not filename
The upload route skipped the shared validateFileType() helper every
sibling upload route uses, and derived the stored extension from the
client-supplied filename. A file could declare an image MIME type
while carrying an executable/HTML extension and arbitrary content,
then be served same-origin via the mass-assignable logoPath field.
* fix(backend): content-sniff business-profile logo uploads too
fileFilter paired the claimed MIME type against the extension but
never verified the actual bytes matched, unlike other upload routes
that already call validateFileContent(). Defense-in-depth: the
extension-confusion XSS itself was already closed (stored extension
is derived from the validated MIME, not client input), this closes
the remaining gap where declared-vs-actual content can still diverge.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(backend): reject a replayed TOTP code within its validity window
verifyTotp() was stateless — otplib's window:1 tolerance meant the
same 6-digit code could complete two independent logins inside its
~90s validity window. Track each admin's last-consumed step and
reject a code that doesn't advance past it.
* fix(backend): make the TOTP replay-tracking persist atomic
verifyTotpEncryptedStep() read two_factor_last_used_step, then a plain
UPDATE wrote the new step with no conditional guard — two concurrent
requests carrying the same captured code could both pass the check
before either UPDATE landed. The persist is now a conditional UPDATE
(only advances the step, checked via affected-row count), so a losing
concurrent request is correctly treated as a replay.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The unauthenticated payment-check magic link (intentional, matches
publicQuotes.js) had a 30-day token lifetime and wrote to the invoice
ledger silently. Shortened the TTL and added a best-effort admin
notification on every write via this route, so the no-login
convenience stays but an admin always sees the action happen.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
GET and POST for an event's short URLs both required
requireEventOwnership; DELETE only checked events.edit permission,
letting any admin holding that permission delete another tenant's
branded gallery short URL. Resolve the short URL's event first, then
apply the same ownership check the other routes use.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(backend): bump sharp, nodemailer, multer, js-yaml, joi for security fixes
Resolves 12 open code-scanning alerts (#589-600): sharp libheif RCE,
nodemailer address-parser ReDoS + domain-validation bypasses, multer
upload DoS/race conditions, js-yaml parsing DoS, and joi prototype
pollution. All patch/minor bumps within the currently used major
version.
* fix(backend): set multer's fieldArrayIndexLimit to actually close CVE-2026-82333
The advisory is explicit that the 2.3.0 version bump alone doesn't
remediate the array-index DoS — an app must also set
limits.fieldArrayIndexLimit. Set it on every multer instance, sized to
what each route's form actually needs.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(video): try metadata extraction and thumbnail generation independently
processUploadedVideo() gated everything behind isValidVideo(), which
rejects the whole video if ffprobe can't read even one of
duration/width/height -- common on some iPhone/Lightroom-exported
MP4s (issue 1370). Callers (photoProcessor.js's processPhoto and
processUploadedPhotos) already catch that throw and fall back to a
static placeholder thumbnail plus a metadata-only retry (codex
review of #845), but that fallback never got a REAL thumbnail even
when generateVideoThumbnail() would have succeeded on its own --
thumbnailing doesn't need valid duration/width/height, it just seeks
and grabs a frame.
processUploadedVideo now tries metadata extraction and thumbnail
generation independently, keeping whichever succeeds instead of
discarding both on a single failed field. The callers' existing
throw handling stays as a backstop.
Also: extractVideoMetadata stored duration as 0 (not null) whenever
ffprobe had no duration field, masking "unknown" as a fake real
zero-second clip and defeating downstream `duration != null` checks
meant to skip an untrustworthy value.
Relates to issue 1370
* fix(video): fall back to the SVG placeholder when thumbnail generation fails
processUploadedVideo could return success with thumbnailKey: null
when only thumbnail generation failed. The gallery grid
(GridGalleryLayout/JustifiedGalleryLayout) falls back to
`photo.thumbnail_url || photo.url` when there's no thumbnail, so
AuthenticatedImage downloaded the full original video and tried to
render it as an <img> -- a broken tile and a potentially huge
fetch just from opening the gallery.
Falls back to the same ffmpeg-free SVG placeholder the callers
already generate for a total processing failure, so a bare
thumbnail-generation failure degrades to that placeholder too,
never to "no thumbnail at all".
Found by codex review.
* fix(video): avoid a SQLite connection deadlock in the placeholder fallback
generateVideoPlaceholder() unconditionally called getThumbnailSettings(),
which queries the database directly (not through any active transaction).
videoProcessor.js's new placeholder fallback can run from inside
processUploadedPhotos' open per-file SQLite transaction (chunked video
upload) -- knex's default SQLite pool has exactly one connection, so
that second, un-transacted query deadlocks against the transaction
holding it, timing out after acquireConnectionTimeout (60s). Reproduced
directly against an isolated SQLite db.
generateVideoPlaceholder now skips the settings lookup entirely when
the caller supplies explicit width/height, and the video fallback
passes the same DEFAULT_THUMBNAIL_WIDTH/HEIGHT the settings lookup
would have fallen back to anyway (now exported for reuse).
Found by codex review.
* fix(video): throw when neither a real thumbnail nor the placeholder can be produced
processUploadedVideo returned success with thumbnailKey: null when
both the real thumbnail AND the SVG placeholder failed -- a total,
systemic failure (storage backend down, disk full), not a quirk of
one file. On stable, which doesn't have the #845 call-site fallback,
this silently completed the video with no thumbnail at all instead
of the retryable 'failed' status a throw here produces. On main,
the pre-existing #845 fallback already absorbed this exact case
(no behavior change there) -- verified against codex's own
git-blame check of the pre-PR stable code before applying this.
Now throws in that case, restoring the pre-existing "let the caller
mark it failed and retryable" behavior for a genuinely unrecoverable
video, while keeping every partial-failure case (the vast majority)
resolving with whatever succeeded.
Found by codex review.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(backup): stop ignoring the configured database-backup destination path
databaseBackupService.getBackupConfig() returns the raw
database_backup_*-prefixed setting keys, but backup() and
startScheduledBackups() destructured unprefixed names off that
object (destinationPath, compress, enabled, schedule,
retentionDays, emailOnSuccess/Failure). None of those keys ever
existed on the config object, so every read silently fell through
to its hardcoded default.
The visible symptom (reported in issue 1365): the inline database
dump that runs before every file backup (default ON) always tried
to create /backup/database, regardless of what an admin configured,
and died with EACCES on the read-only default path — before the
file backup's own (correctly wired) backup_destination_path was
ever reached. The standalone scheduled database-backup runner had
the same bug: config.enabled was always undefined, so it silently
never started regardless of database_backup_enabled.
Also fixes saveManifestToLocal's manifest-directory fallback,
which hardcoded /backup instead of matching the sane
getStoragePath()/backups default used everywhere else for a
missing backup_destination_path.
Relates to issue 1365
* fix(backup): reject a database-backup destination inside a public static mount
Making database_backup_destination_path actually take effect
reopens a GHSA-jw8m-43r2-jqrm-class exfiltration path: that
setting is writable via PUT /api/admin/database-backup/config
under backup.create alone (the built-in admin role has it
without settings.edit or backup.restore), with no path
validation. Before this fix the setting was silently ignored
(the destructuring bug), so pointing it at the public
uploads/logos or fonts mount was harmless; now that it is
honored, it needed the same defense GHSA-jw8m already applies
to the per-request override.
Rejects the setting at both the config write (immediate 400)
and, defensively, at backup() time before mkdir.
Found by codex review.
* fix(backup): close two gaps codex round 2 found in the destination guard
- The public-roots list missed the bundled fallback fonts dir
(backend/assets/fonts, also mounted at /fonts, and nodejs-owned
per the Dockerfile's COPY --chown so it's writable at runtime).
- The comparison was case-sensitive; on a case-insensitive-but-
preserving filesystem (APFS, NTFS, Docker Desktop bind mounts of
either) STORAGE_PATH/UPLOADS/Logos names the same directory as
uploads/logos on disk. Now compares lowercased.
- database_backup_retention_days reached cleanupOldBackups
unvalidated. A value <= 0 pushes the cutoff to today or the
future, deleting every completed backup on the next scheduled
run -- a backup.create holder achieving what backup.delete
gates on the manual /cleanup route. Rejected at config-write
time (400) and defensively inside cleanupOldBackups itself.
- The scheduled-backup cron callback closed over retention_days
from schedule-start time; a retention-only /config update
(which doesn't restart the schedule) ran stale until restart.
Re-reads it on every tick instead.
Found by codex review, round 2.
* fix(backup): resolve symlinks and add the all-in-one frontend dir to the destination guard
Codex round 3 found two more bypasses of the public-root guard,
both specific to the all-in-one image (Dockerfile.aio):
- /app/frontend/dist (FRONTEND_DIR) ships nodejs-owned and is
served unauthenticated as the built SPA -- missing from the
protected-roots list.
- /app/storage is a symlink to /data/storage (the actual
STORAGE_PATH). A destination given as /app/storage/uploads/logos
passed the guard's lexical path.resolve() comparison while
resolving, on disk, to the exact same directory as the protected
STORAGE_PATH/uploads/logos. isUnderPubliclyServableRoot now
resolves symlinks in whatever prefix of each path already
exists (resolveRealish) before comparing, rather than relying on
path.resolve() alone.
Also restores three fs.mkdir spies in the test file that were
never un-spied, which silently leaked a rejected mock into any
later test doing a real fs.mkdir -- exactly what the new symlink
test needed to set up its fixture.
Found by codex review, round 3.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(usage): explain and de-emphasize the pending-packet button lock
An admin whose report delivery is stuck (old schema, network issue,
etc.) saw the v5-upgrade and portal buttons greyed out with no
indication why, or what to do about it — the backend's single-packet-
in-flight guard is correct, but silent. Add an inline note pointing at
"Retry / send if due" when a pending packet is the actual cause.
Also: "Review expanded usage.v5 scope" didn't read as an upgrade
action — renamed to "Upgrade to usage.v5" / "Auf usage.v5 upgraden".
"Open usage portal" is now a primary (green) button in both its
signed-in and pre-participation forms, matching the visual weight of
the other primary actions on this tab instead of blending in as a
secondary outline button.
* fix(usage): scope the pending-packet note to controls it actually gates
The note added in the previous commit rendered whenever pending_action
was truthy, regardless of participation status. Outside `active`
(activation_pending, deletion_pending) the portal renders as a plain
un-gated link and no v5-upgrade section exists at all, so the note
named two controls that either weren't blocked or weren't on screen.
And when active but already on the current schema, it wrongly implied
a v5-upgrade button existed.
Gate on `active` (nothing is actually blocked outside it), and choose
between the existing two-control message and a new portal-only one
based on whether consent_update_available — which the v5-upgrade
section itself is gated on — is true. Four new regression tests cover
each shape: activation_pending, deletion_pending, active+current-schema
(portal-only), and active+outdated-schema (both, the original case).
Found by code review.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
#1359 independently added 211_revocations_without_expiry.js against
the same main baseline. Renumbering this one to 212 keeps the
migrations directory sequentially numbered once both land, regardless
of merge order. No functional change — same up()/down(), same column.
Found testing against WebKit at an iPhone-sized viewport: the fixed
overlay + flex-center wrapper has no height limit, so on a short
screen the title is clipped off the top and the "Maybe later" button
off the bottom, with no way to reach either. Capping the card at
max-h-[90vh] with its own overflow-y-auto keeps the backdrop static
and makes the card scroll internally instead.
An admin who already had PicPeak installed before the opt-in reporting
feature existed never gets asked — the setup wizard only runs once, on
a brand-new instance. Adds a one-time modal, shown on the admin's next
dashboard visit after updating, offering the same choice the wizard
gives a new install.
- New `product_usage_state.prompt_shown` column (migration 211) and
UsageService.markPromptShown(), set on either outcome (enable or
decline) from both this modal and the wizard step, so an
installation is never asked twice regardless of which path it took.
- New POST /admin/usage/prompt-seen endpoint.
- Extracted the wizard's three-point pitch (UsageReportingPitch.tsx)
so the modal and the wizard step share identical copy instead of
drifting apart.
- The modal never shows once participation is already active, and
never shows a second time after either the wizard or the modal has
been through it once.
Depends on #1360 (the setup wizard step this reuses).
Adds a step between the SMTP/site-URL config step and the final
thank-you screen, asking whether to participate in the existing
Product usage & feedback reporting. Kept short — three points on how
it differs from typical telemetry (one-way only, no personal data,
and participants can browse the same shared feature-adoption dataset
other installations report) plus an explicit consent checkbox — and
calls the same enable() endpoint the post-install Settings page uses.
Skipping does nothing; participation can be toggled anytime from
Settings → Product usage.
Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
generatePreviewImage rewrites the output extension to match the encoding
it chose, .jpg or .webp for alpha and multi-frame sources. The tier
lookup in ensurePreviewImageAtWidth and the cleanup list in
previewTierKeys kept the SOURCE extension instead, so for anything but a
lowercase .jpg source the stat never matched: every tier request for a
.png, .JPG, .jpeg, .heic or RAW photo re-ran Sharp, and cleanup never
found the files it left behind, which accumulated for the life of the
install.
Both now derive every key the tier can live under: the .jpg and .webp
candidates, plus the source-extension key last so tiers written before
the rewrite are still found by lookup and by cleanup.
Follow-up to issue 1020, where the mismatch was identified during review.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(images): single-flight lazy rendition generation and keep the old rendition during replacement
The lazy generators in imageProcessor are check-then-generate, and the
check reads the path off the photo row the route already fetched. N
concurrent cold requests for one photo all held a snapshot with the path
still null, all missed, and all ran the same sharp pipeline. Only the
thumbnail tier path had a guard; the canonical thumbnail it falls back
to, heroes, previews and preview tiers had none.
One process-local map now covers every rendition, keyed by photo id and
rendition (`thumbnail:<id>`, `thumbnail:<id>:w<width>`, `hero:<id>`,
`preview:<id>`, `preview:<id>:w<width>`). Concurrent callers share one
promise; the entry is cleared in a finally on success and failure alike
so a rejection cannot poison the key. The tier stat moved inside the
flight so a request arriving as the previous flight clears finds the
written tier instead of missing on a stale probe.
Heroes and previews also deleted the existing object before generating
its replacement, and again in the catch. Both are gone, mirroring what
the thumbnail generator already does: put is the last statement in the
try and replaces atomically on local storage and by key on S3, so the
delete only ever opened a window with no rendition at all, and a source
that failed to read stripped the old rendition with the row still
pointing at it.
No re-read of the photo row inside the flight: the admin regenerate
endpoints force a rebuild by passing a row with the path nulled, and a
re-read would hand back the persisted rendition untouched.
Fixes the single-flight half of issue 1020. The preview cache-key
extension mismatch and any server-wide work queue remain separate.
* fix(images): keep the snapshot validity check outside the single-flight
With the check inside the flight, an admin regeneration (row passed with
the path nulled) could join a viewer's flight for the same photo that was
merely confirming an already-good rendition, and be handed back the very
file it was asked to replace while the endpoint counted a success. Only a
miss enters the flight now; inside it everything is a regeneration.
* fix(images): forced rebuilds run after an in-flight lazy generation instead of adopting it
The admin regenerate endpoints could still join a lazy flight that was
already generating for the same photo. That flight read the thumbnail
settings when it started, so after a settings change it produces exactly
the rendition the regenerate was invoked to replace; adopting it counted
a success while the old size stayed cached.
ensureThumbnail and ensurePreviewImage take `{ force: true }`: skip the
snapshot check and, if a flight is pending, start after it settles. Lazy
misses arriving meanwhile join the forced flight, and an older flight
settling late no longer evicts the newer entry from the map.
* fix(images): key rendition flights by source as well as photo id
replacePhoto keeps the photo id and changes path and filename. Keyed by
id and width alone, a request carrying the replacement row joined a
flight still rendering the file it replaced and was handed the old
image, which the gallery caches for 30 minutes. The tier map this
replaced was keyed by storage key and so already told the two apart.
* test(thumbnails): wait for the regenerate loop's completion line instead of a fixed 150 ms
The loop runs in setImmediate after the response. Under a loaded machine
(fifteen suites in parallel, each booting a migrated SQLite) it took
longer than 150 ms once and the assertions ran against a half-finished
mock call list. Poll the logger spy for the "regeneration complete" line
with a 10 s deadline; the suite also finishes sooner because the wait
ends as soon as the loop does.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
PUT /admin/events/:id spreads the body into the UPDATE. SQLite resolves
quoted identifiers case-insensitively, so `{ "Event_Name": ... }` lands
on event_name there — while every check in the handler (validators, the
field-level permission guards, the deny-set) keys on the exact lowercase
name. The deny-set already case-folded for its own columns; every other
column was reachable through a spelling variant.
Every events column and every input-only key the handler accepts is
lowercase snake_case, so a key with any uppercase in it is not something
a legitimate client sends. Such keys are now removed before anything
looks at the body. Postgres was unaffected (quoted identifiers are
case-sensitive there; a variant produced a 500 instead).
Surfaced by the Codex review of the folder-watcher change, where a
photos.upload guard on external_watch could be walked around this way.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(external-media): watch reference folders and import new files automatically
Managed uploads dropped into storage/events/active are picked up by the
chokidar watcher; external media had no equivalent, so a NAS folder that
keeps growing needed an admin to open the event and press Import every
time. Relates to issue 1187.
- The import pass moves out of the route into
services/externalImportService.js. The watcher and the Import button
now run the identical function; the route only validates and maps
errors to status codes.
- Mutual exclusion is the per-event claim from maintenanceJobState
(`external_import:<id>`, seeded on demand by the new ensure()) instead
of the in-process Set. The Set stopped a double-click in one process;
the claim also stops the watcher on a second replica, or an admin
clicking while the watcher is mid-run elsewhere. The run heartbeats so
a claim from a dead process is taken over.
- services/externalMediaWatcher.js: per-event opt-in via the new
events.external_watch column (migration 208), chokidar with
awaitWriteFinish so a copy in flight is not imported half-written,
debounced full pass per change, a timer sweep every 15 minutes as the
fallback for NFS/SMB mounts that deliver no inotify events, optional
stat-polling via EXTERNAL_MEDIA_WATCH_POLLING. The set of watched
events is re-read every minute, so the toggle works from any replica.
A watcher that just started runs one pass immediately.
- Deletions are ignored on purpose: a file vanishing from a NAS is at
least as likely to be a reorganisation or a dropped mount as an
intentional removal, and acting on it would delete a guest-visible
photo. Rows whose file is gone stay, as they do today.
- Not gated on STORAGE_BACKEND: EXTERNAL_MEDIA_ROOT is always local.
- Quiet system passes stay out of the activity log; runs that imported
something are logged with actor external-media-watcher.
- Frontend: "Watch folder for new files" checkbox under the external
folder picker, status line in view mode, EN/DE strings.
* fix(external-media): close the review gaps in the folder watcher
Codex review of the watcher, round 1. All six findings were real:
- Enabling the watcher, or pointing an enabled one at another folder,
now requires photos.upload — the permission the manual Import already
requires. events.edit alone was a way around it. Only the transition
is checked, so a role without photos.upload can still edit an
already-watched event. The checkbox is disabled for such roles.
- Automatic passes defer files that are still changing: anything
modified inside the stability window, or whose size moves across one
wait of that window, is left for the next pass. chokidar's
awaitWriteFinish only settles the file that fired the event, and the
sweep sees no events at all, so a sibling still being copied could be
inserted half-written and then skipped forever.
- Photos an admin deleted are not brought back by the sweep. The delete
routes record the file in external_import_exclusions (migration 209);
automatic passes skip the list, the manual Import ignores it and
clears it for what it imports.
- The six EXTERNAL_MEDIA_WATCH* variables are forwarded in all three
compose files; they were documented but the backend services use
explicit environment lists, so the kill switch did nothing.
- A pass re-checks is_active / is_archived at run time, not only in the
minutely reconcile.
- The lease is renewed on a timer for the whole run, walk included, and
ownership is checked before the event row is touched.
* fix(external-media): make automatic passes follow the row, not rewrite it
Codex review round 2, four findings, all applied:
- The event update route drops non-canonical spellings of external_watch
and external_path before the permission guard. SQLite resolves column
names case-insensitively, so `External_Watch` reached the column while
the guard only looked at the lowercase key.
- Exclusions are checked per file at insert time, not against a
snapshot taken before the settle wait. A photo deleted during the wait
was present in the snapshot and got re-inserted by the loop.
- An automatic pass no longer writes source_mode / external_path. It
re-reads the row after the walk and the settle wait and stops if the
folder changed or the event went managed; the manual Import is the
only writer. The options are now `automatic` + `settleMs`.
- A pass that deferred files re-arms the debounced import, so a file
copied just before the watcher started is not stranded when the sweep
is disabled.
* fix(external-media): keep exclusions for replaced photos, stop a pass whose event stopped qualifying
Codex review round 3, both findings applied:
- recordExclusions keys on external_relpath alone. A replaced external
photo becomes managed but keeps its relpath on purpose, and deleting
that replacement must not republish the NAS original.
- An automatic pass checks the full watcher predicate (reference mode,
same folder, watch on, active, not archived) before it inserts and on
every heartbeat tick during the loop, and stops as soon as the event
no longer qualifies.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
On the CMS page the editor has no bounded height, so a long document
scrolls the whole admin content area and the toolbar scrolled away with
it. Editing a 16-section privacy policy meant scrolling back to the top
for every heading or list.
The top toolbar block (mode/save row and formatting row) is now
`sticky top-0` from the md breakpoint up, pinned to the admin page's
scroller. The rounded wrapper clips with `overflow-clip` instead of
`overflow-hidden`, because hidden turns the wrapper into a scroll
container and the toolbar would pin to that instead of to the page. Not
below md: there the formatting row wraps to several lines and a
permanently stuck block would eat most of a phone's editing area.
Two things follow from pinning. The link-entry row moves inside the
sticky block: rendered below it, the URL field sat at the toolbar's
original document position, under the pinned toolbar. And ProseMirror's
selection scrolling gets a top threshold and margin sized from the
block's rendered height (ResizeObserver, re-applied through
editor.setOptions), because the formatting row wraps to two rows at
common desktop widths and the link row comes and goes; a constant would
leave the caret behind the toolbar half the time. Below md the offsets
are zero again.
Verified in Chromium against the CMS page with an 18-section document:
scrolled to the last sections, the toolbar stays at the top of the
content area; on main it is gone. A source-level test pins the sticky
block, the wrapper's clip, the link row's placement and the measured
offsets, since jsdom does not lay out.
Relates to issue 1289
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(security): opt-in recoverable gallery passwords
Gallery passwords are bcrypt hashes, so an admin who needs to hand a
password to a client a second time has to reset it, which invalidates
what the client already has. This adds a security setting,
security_gallery_password_recoverable, off by default, that keeps an
AES-256-GCM encrypted copy of each gallery password and client PIN next
to the hash. The key is derived from GALLERY_PASSWORD_ENCRYPTION_KEY or
JWT_SECRET.
While the setting is on:
- create, publish, send-later, edit, reset and the v1 API write the copy
alongside the hash; turning a gallery's password requirement off
clears it
- GET /api/admin/events/:id/password returns the copy to admins with
events.edit and ownership, and writes a gallery_password_viewed
activity entry on every real reveal
- resend-email uses the stored password instead of the "set at creation"
sentinel, so the client receives what already works
Switching the setting off purges every stored copy. Login and hash
verification are untouched; the copy is never read on the gallery side.
The Security tab carries the toggle with a warning that stays visible,
and the event page shows "Show password" with copy buttons only while
the setting is on and the gallery has a secret.
Relates to issue 1271
* fix(security): close the write-versus-switch-off race in the password vault
The recoverable setting is read while an event insert is assembled and the
client-PIN hash awaits after that, so a settings request that switched the
feature off and purged in that gap was overtaken by the insert. Every write
site now re-reads the setting right after its statement and clears its own
row when the setting is off; the settings writer flips the value before it
purges, so either the purge or the re-check catches the row.
* fix(security): resend carries the stored client PIN and link; deterministic tamper test
The creation mail includes the client-access link and PIN; a resend only
sent the gallery password even when a stored PIN was available. The
ciphertext tamper assertion replaced the last two characters with a
constant, which was a no-op roughly once in 4096 runs.
* fix(security): drop the revealed password after Send gallery email
The send-later route can replace the password; the share card keys its
revealed copy on the event query's refetch time, so invalidate the event
after the send like the other password-changing mutations do.
* fix(security): purge leftovers before the setting write when turning recovery on
Switching on wrote the setting first and purged after, so a password write
that read the new "on" in between stored a copy the purge then deleted.
Turning on now purges before the write; turning off keeps purging after it,
which together with the write-site re-check leaves the vault holding
exactly what was written while the setting was on.
* chore(security): drop the duplicate rateLimitService import left by the rebase
* chore(usage): register the password recovery routes in the v5 coverage inventory
The inventory moved from v4 to v5 on main; the entry added by this branch
followed it.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(usage): distinguish real edits and template delivery with v5 consent
* fix(usage): exclude queued test messages and count reorders as edits
- queueEmail carries usageEligible: false into email_data and the queue
processor passes it on, so the dev tools' send-test-email no longer
records email_template_delivery once the worker sends it.
- event-types/reorder and categories/reorder-global compare the persisted
order before and after and record the v5 edit markers only when it
changed, matching the display_order edit already counted on PUT.
- normalized() builds arrays with Array.from so a row array from the sqlite
binding compares equal under Jest's separate realm.
* fix(usage): cover per-gallery category order and workflow test runs
- categories/reorder records category_editing when an event's override
changes; reorder/:eventId records it when an override was actually
removed.
- send_email and the collections handoff pass usageEligible: false for a
workflow test run (engine.testRun sets __test), so a non-dry test send is
not counted as template delivery.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(email): scrub gallery passwords from the sent-mail archive
The email queue kept every gallery password and client PIN in clear
text in email_data and rendered_html after the mail was sent, and the
Messages reading pane handed them back to any admin with the messaging
flag. A password hash in the events table bought nothing while the
plaintext sat next to it.
Once a mail is out, or its retries are exhausted, the processor now
masks secret-looking variables (password, passcode, pin) in email_data
and replaces their values in the rendered body, plain and HTML-escaped.
The reading pane applies the same masking to rows archived before this
change. Pending rows keep the real values so a retry still sends them.
Relates to issue 1271
* fix(email): keep a quoted ">" from cutting an attribute value out of redaction
The tag splitter stopped at the first ">", so a template attribute such as
title="{{gallery_password}} > details" left the password unmasked in the
archived HTML while email_data was already masked. The tokenizer is now
quote-aware; a tag with an unbalanced quote falls through as text and is
scrubbed there.
* fix(email): scrub secrets inside HTML comments in the archived body
A comment such as <!-- PIN: {{client_password}} --> was split off as a tag
and its body, which has no attribute, was never scrubbed. Comments are now
one segment and their content is masked whole.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(settings): expose the API rate limiter in the Security tab
The general per-IP limiter had six settings in app_settings and a
backend route to write them, and no screen. Installs ran on the code
fallback — 300 requests per 15 minutes per IP — with no way to see it,
which is how issue 1287 played out: a 546-photo gallery exhausted the
budget for one viewer and the operator learned about the setting from
a grep of the backend log.
Security tab: a card with the six settings, the validation ranges the
route enforces, a one-line explanation per field, and a note that the
unit is the client IP — an office or household behind one NAT shares
a budget, and behind a proxy TRUST_PROXY has to cover the proxy or
every visitor shares its address. The tab's Save button saves the
limiter through its own route. The limiter values are checked against
the route's ranges before anything is written and the limiter is
written first, so a rejected value cannot leave the password/session
settings half-saved behind a failure toast.
Backend, three things the screen needed:
- The settings read fills the six keys with the code defaults when
they have no row, so the form shows the budget in force rather than
an empty field; the defaults live in one exported constant the
limiter itself reads.
- The write route upserts instead of updating: on a fresh install,
which has no rows, the old UPDATE matched nothing and the route
answered 200 while changing nothing.
- The live limiter instances move into rateLimitService and the
write route rebuilds them. express-rate-limit fixes windowMs when an
instance is built — max and skip re-read the settings per request,
the window does not — so a saved window used to apply only after a
restart. The gates in server.js resolve the instance per request
through the service's getters. A rebuild starts fresh counters,
which on a settings change is acceptable. The limiters get explicit
MemoryStores and a rebuild shuts the superseded ones down, because a
store keeps a cleanup interval alive for as long as it exists and
dropping the reference alone would leak one timer per save.
Tests: the read surfaces defaults and honours the key filter; the
write creates rows on a fresh database, the limiter sees the values
immediately and hands the gates a fresh instance; existing rows are
updated not duplicated; out-of-range values are rejected. The tab
renders the values, edits through the hook state, carries the ranges,
saves with the tab's button, and the pre-write validation accepts the
bounds and rejects outside them and cleared fields.
Relates to issue 1337
* docs(security): point the rate limiter doc at the Security tab and the upsert
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* docs(security): correct the rate limiter defaults and how they are set
SECURITY_LOGGING.md said rate_limit_max_requests defaults to 1000 and
that the settings are configurable via the admin panel. The code
fallback when no app_settings row exists is 300 (19e125d8), a fresh
install has no row, and there is no admin screen: the settings are
written by PUT /api/admin/settings/security/rate-limit, which nothing
in the frontend calls. The reporter of issue 1287 ran on the 300
default without any way to see it.
The table now carries the real defaults, what the auth budget counts,
the exemptions including the gallery-image one from v3.127.0-beta.0,
and the TRUST_PROXY and shared-NAT caveats.
Relates to issue 1287
* docs(security): note the rate-limit route only updates existing rows
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
In Safari the product-usage consent dialog opened as a 302px box on a
714px viewport with the disclosure text squeezed into a 32px strip
between header and footer. The dialog is a native <dialog> laid out as
a flex column with only a max-height, so its own height is indefinite,
and the disclosure region used `flex-1`, which is `flex: 1 1 0%`.
WebKit resolves that 0% basis against the indefinite container height
as zero: the region's hypothetical size is zero, the dialog sizes to
header plus footer, and the max-height never comes into play. Chromium
treats the same basis as `content` and was fine.
An `auto` basis with min-height 0 sizes the region from its content and
lets it shrink to the max-height: measured in iOS Safari, 641px dialog
and a 371px scrolling region, footer on screen, matching Chromium.
Only the <dialog> is affected. The div-based modals that use the same
`flex-1 overflow-y-auto` pattern inside a max-height column were
measured in the same WebKit and size correctly, so they stay as they
are. A test pins the classes with the reasoning, since jsdom cannot
see the layout.
The block mixed Sie and du (four strings duzed, the rest siezte), used
two words for the same credential (Lookup-Hash, Abfrage-Hash) and two
for the same people (Maintainer, Betreuer), and carried a handful of
strings that read like a translation rather than German: "Die
Übertragung benötigt Aufmerksamkeit", "rundheraus abgelehnt",
"Integriert bedeutet verfügbar, nicht genutzt", "feste Galerie-Layouts"
for controlled layouts, "Erneut versuchen / fälligen Bericht senden" as
a button label.
Now du throughout (the form the maintainer chose for this area),
Abfrage-Hash and Betreuer everywhere, Collector left as the product
name it is, and the clunky strings reworded. No key added or removed;
meaning unchanged, so nothing here touches consent.
The "open usage portal" button was a plain link, so an operator who
wanted to see their own data had to copy the lookup hash out of the
settings page and paste it into the portal. Next to it sat a second
control, "connect to requests & voting", which minted a collector
session and then showed a third thing, a link to open it.
One button now. Before participation it stays the plain link: the
portal is public and someone deciding whether to join should be able
to look at it first. While participating, a click asks the backend for
a collector session (a signed `session` command, so the collector knows
which installation this is) and opens the portal with that token in the
URL fragment. Fragments are never sent over the wire; the portal drops
it from the address bar on load and keeps the session in memory only.
The lookup hash itself never leaves the settings page, and no URL a
server or an access log sees ever carries a credential.
The tab is opened synchronously in the click handler and navigated once
the session exists, because opening it after the await trips popup
blockers. If the collector cannot be reached the session command is
queued for retry and the tab falls back to the public portal, so the
click still lands somewhere; a failed request closes the tab again. The
separate connect button and its session link are gone, and so are
their strings.
The only way into the usage portal from the settings tab was the
session-bound link behind "Connect", which needs an active participation
and creates a 15-minute voting session. An operator deciding whether to
join had no way to look at the portal first. The status card now carries
a plain "Open usage portal" link to the collector base URL the status
endpoint already reports, shown whenever that URL is valid, opening in a
new tab with rel="noopener noreferrer". The session link stays as it is.
German "Teilnahme prüfen" read as a technical check rather than reviewing
the consent details; it is now "Details ansehen" in both places the key is
used (notice link and opt-in button). The actual opt-in stays
"Produktnutzung aktivieren".
Relates to issue 1317
features.v2/v3/v4.json carried every name, description and definition
twice, once in English and once in German, inside a file that is source
code, is vendored byte-identically into the collector and is served as
the consented catalog. Source stays English. The German strings already
existed a second time in the frontend locale file, which is what the
consent dialog actually renders (UsageCatalog reads
productUsage.catalog.<key>, never the JSON), so the copy in the catalog
was a duplicate that could only ever drift.
The `de` fields are gone from all three catalogs, their frontend copies
and the inventory definitions; the docs coverage file and
FEATURE_COVERAGE.md list English only. Nothing on the wire changes: the
report schema is derived from the keys, and the catalog's text is not
part of any signature or consent version string.
The coverage test now pins the catalog to the en locale verbatim and
requires the de locale to cover every key and field, without dictating
its wording. The collector holds the same catalog files and needs the
same change to stay byte-identical, plus its German catalog strings
moved into its own locale file; that is filed there.
Trivy flags the backend image on two sanitize-html advisories, both
fixed upstream:
- CVE-2026-63670 (fixed 2.17.6): a literal solidus after a raw-text end
tag (`</textarea/>`) is treated as text by htmlparser2 and re-emitted
unescaped, so disallowed markup passes when textarea or xmp is in
allowedTags.
- CVE-2026-84371 (fixed 2.17.7): an SVG SMIL animation whose
attributeName selects href lets the sibling values/from/to/by
attributes carry URLs past the scheme policy.
2.17.5 -> 2.17.7, exact pin as before. The new version brings its own
htmlparser2 12 / domhandler 6 / domutils 4 / dom-serializer 3 /
entities 8 tree under node_modules/sanitize-html; nothing else in the
lock moves.
That tree is ESM-only, so the backend now needs unflagged require(esm):
Node 20.19+ or 22.12+. The image is node:22-alpine and CI runs 22, but
engines.node still admitted 22.0-22.11, where require('sanitize-html')
throws ERR_REQUIRE_ESM at startup (publicSiteService loads it during
initialisation). engines is now ^20.19.0 || >=22.12.0 and the native
setup script's Node check enforces the same range instead of accepting
any 22.x. On the supported versions the sanitiser behaves identically
to 2.17.5 on the tracker and newsletter fixtures.
Jest 29's CommonJS registry cannot evaluate ESM either, so every suite
importing a route or service that uses the sanitiser would fail at
import. jest.config.js now maps `sanitize-html` to jest.sanitizeHtml.js,
which hands that one module to Node's real loader via
process.getBuiltinModule('module') — a plain require('module') inside
Jest is Jest's wrapper and returns an empty object for this package.
Verified against a real 2.17.7 install: the sanitiser suites and a
settings route suite pass; without the mapper they fail with "Cannot
use import statement outside a module".
The general per-IP limiter was inert until b0f33c17 registered it ahead
of the routers (budget 100, raised to 300 by 19e125d8), and 839bf4e4
then stopped gallery tokens from earning the authenticated skip. Since
that release every guest has been paying for the gallery's thumbnails
out of 300 requests per 15 minutes per IP. A 546-photo grid runs out
mid-scroll: the remaining tiles come back 429, which the frontend
turned into blank tiles with no error and no retry, and the next
refresh finds the photo list limited too.
Reproduced in iOS Safari against a seeded 546-photo Grid gallery:
loading in bursts, then nothing, no console output, no recovery — the
exact shape of the large-gallery report, whose plateaus were 125, 235,
300 and 308 tiles.
A token that verifies and names the gallery in the path is now exempt
on the image routes only: thumbnail, preview, hero and photo, GET only.
The photo list, downloads, feedback and every write stay on the budget,
a token for one gallery buys nothing on another, and the exemption
rides on skip_authenticated so an operator who turns the skip off
counts guests too. The 839bf4e4 concern — a free token as an unlimited
budget on every /api route — stays closed; what this hands back is the
bandwidth of routes a 300-request budget never bounded anyway.
Relates to issue 1287
Every tile, the hero and the folder covers switched to a <canvas> when
the per-event toggle was on or the protection level was `maximum`. A
canvas pins a backing store of naturalWidth × naturalHeight × 4 bytes
that the browser is not allowed to evict, and iOS Safari has a hard
budget for canvas memory that fails silently when exceeded — blank
tiles, no error, on exactly the browser the large-gallery report came
from. A gallery is several hundred tiles and one lightbox image.
What canvas buys on a thumbnail is a slightly harder right-click. What
actually protects the images is server-side: the served file is
watermarked and the download route refuses when downloads are off. The
photographer who reported the large-gallery case, shipping to real
clients, said the same and turned the global toggle off once it was
about to reach their next gallery.
So: tiles, hero and folder covers always render <img>. The lightbox
keeps both the per-event toggle and the `maximum` implication — one
image, where the calculus is different. The toggle is now wired to the
lightbox for the first time; before this it reached only the tiles, so
the label that said "canvas rendering" turned every grid into canvases
and left the lightbox alone. Labels in all four locales now say where it
applies.
`protectionLevel` was destructured in seven tile components only to feed
that OR; those props and their pass-throughs go with it. The shared
layout props keep it, since the story layout still hands it to its
lightbox.
A source-level test pins that only PhotoLightbox passes
useCanvasRendering to AuthenticatedImage or turns it on for `maximum`.
Relates to issue 1287
Current Umami script.js exposes `window.umami = { track, identify }`;
`trackView` was the v1 API. `trackPageView()` called it unguarded, so
every admin route change threw
`TypeError: window.umami.trackView is not a function`.
`trackPageView()` now prefers `track(fn)` with the sanitized URL merged
into the tracker's default payload (no `name` = page view), falls back to
`trackView` only on a legacy script, and no-ops when the script has not
loaded yet or offers neither. The call is wrapped so a throwing tracker
can never break navigation. The `window.umami` typing marks the legacy
methods optional so the compiler enforces the guard.
Relates to issue 1316
gallery_downloads.configured was true on every installation with a
gallery. allow_downloads ships true — column default in migration 037
and the create route both set it — and the snapshot asked "at least one
gallery has it on". The fleet value was ~100% by construction and could
not separate a deliberate configuration from an untouched one.
v2 consented to that key under that description, so v2 keeps sending it
unchanged. v3 replaces it with gallery_downloads_restricted: at least
one gallery has downloads switched off, which is the only state of that
column anyone actually decides. Same catalog position, so the disclosed
capability count stays at 86; the frontend copy, the EN/DE catalog
strings, the coverage inventory and FEATURE_COVERAGE.md follow.
Done in v3 rather than a v4 because v3 is on main and in no release
yet, so nobody has consented to it. The collector carries the same
catalog and has to take this change before the release that ships v3.
One guard for the window in which :main / :beta images already carried
the old v3 catalog. A report queued under it fails local validation on
this build, and deliver() left a locally invalid report pending for
good, blocking every operation behind it. A report's payload is derived
state, so deliver() now rebuilds it from the current snapshot in place
and sends that. Packet ID and sequence are kept — a re-signed retry has
to reuse them so a lost acknowledgement does not duplicate data — and
reports only: a stale registration, deletion or command is a genuine
conflict and keeps the existing handling.
Tests: the v3 snapshot counts a switched-off gallery and ignores
enabled ones, v2 still reports the old key with the old meaning, and a
stale queued report goes out rebuilt under the same packet id while a
valid one is sent untouched.
Relates to issue 1308
A rejected fetch in AuthenticatedImage set the error state, rendered
nothing, and never asked again. The fetch effect only re-runs when its
inputs change, and for a grid tile they never do — so a transient
failure (a hiccup on cellular, or Safari cancelling loads when the tab
goes to the background) was a permanently blank tile with no request in
flight and nothing in any log. Grid passes no fallbackSrc, so there was
not even a broken-image icon to point at.
The retry is bounded and gated. Three attempts with a doubling delay
(2 s, 4 s, 8 s), and an attempt fires only once the placeholder
intersects the viewport and the document is visible, so a tile that
failed while the user was away retries when they come back rather than
while they are still gone. A new src gets a fresh budget. The
fallbackSrc path is untouched: it already renders a plain <img> and
should not loop.
Two refinements from review. A final 4xx (anything but 408 and 429)
exhausts the budget at once: an expired gallery token or a missing
photo cannot be retried into existence, and on a 68-tile viewport three
retries each would be ~200 requests that cannot succeed. And a 429's
Retry-After is honoured as the minimum delay, because the backoff alone
would spend every retry inside a 15-minute rate-limit window and leave
the tile blank after the limit had lifted. Retry-After is not
CORS-safelisted, so server.js now exposes it for split-origin
deployments alongside Content-Disposition.
The error branch now renders the same grey box as the loading state
instead of null. That is what the retry effect observes, and it is
also something the user can see. The empty-src branch now clears the
error flag too, so a tile whose src is removed after a failure does not
keep showing the failure box.
Nine tests in AuthenticatedImage.retry.test.tsx; the retry cases fail
against the previous version.
Not presented as the fix for the iOS report. It closes the one gap that
turns a transient failure into a permanent one, which the reporter asked
for in the original issue, and it is worth having on any device.
Relates to issue 1287
The pre-load band made tiles arrive in time. It did nothing about them never
leaving. PhotoCard latched its observer with triggerOnce, so a tile that had
been scrolled past stayed mounted for the life of the page — holding its
object URL, and where image protection is on a canvas sized to the image that
the browser is not permitted to evict.
Measured in Chrome on a seeded 546-photo grid, scrolling top to bottom:
mounted tiles climb 24 → 100 → 212 → 364 → 546 and never fall. That is a
monotonically growing retained set, which is the profile a memory-constrained
browser discards a tab over — the reported symptom on iOS Safari 18.1 being
tiles that stop appearing and a blank page after refresh. With this change the
same scroll peaks at 68.
PhotoCard now takes an optional outer band. The inner band, unchanged, decides
when a tile starts loading; the outer one decides when it is far enough away
to unmount, and unmounting is what actually frees anything, because
AuthenticatedImage revokes its object URL and drops the canvas in its cleanup.
The gap between the bands is the hysteresis: at three viewport heights against
a one-viewport load band, a tile travels two further viewport heights after it
stops loading before it is released, so ordinary scrolling never crosses both
edges. Thumbnails are served private, max-age=1800, so returning costs a cache
hit rather than a round trip.
Opt-in per layout, and only Grid opts in. Its skeleton is aspect-square and
holds the tile's box exactly, so releasing shifts nothing; the measured
layouts have no such guarantee. Without the prop the observer keeps its
original latch, so every other layout behaves exactly as before — pinned by a
test, since that is the half most easily broken by accident.
This is not presented as the fix for the iOS report. It removes the mechanism
that best explains it, and it is worth having on any device; whether it is the
mechanism still needs a measurement from the phone that failed.
Relates to issue 1287
Probing the live collector to settle the delete-sequence question turned up
something else: usage.picpeak.app answers a valid usage.v2 registration with
INVALID_PACKET while the identical v1 flow is accepted. It does not speak v2
yet — which the deployment notes already require, but the consequence of
getting that order wrong was worse than "reports do not send".
Opting in to v2 against a v1-only collector left the installation stuck.
Registration was refused, so nothing existed at the collector at all; the row
sat in activation_pending, disable moved it to deletion_pending, retry was
futile forever, and enable refused because the row was not `disabled`. The
abandon hatch added earlier did not apply: it was gated on
SIGNING_KEY_UNREADABLE. So the most harmless possible failure — nothing
registered anywhere — was the one an operator could not clear.
The gate is now the property that actually matters: a participation the
collector has provably never accepted (sequence 0, no receipt) with a failing
delivery can be discarded, from activation_pending as well as
deletion_pending. Its receipt records `never-registered` rather than an
unconfirmed deletion, because nothing remote exists to be unsure about. A
participation the collector *did* accept keeps the old narrow gate and its
explicit warning — clearing local state while the collector still holds
reports must stay a deliberate, warned-about act.
A collector that rejects a registration or a deletion outright now reports
SCHEMA_NOT_ACCEPTED instead of DELIVERY_FAILED, and the settings page says the
collector does not accept this report version yet. Retrying cannot fix that,
and sending the operator to look for a network fault they do not have was
wrong.
Verified end to end against the live collector: v2 opt-in reports
SCHEMA_NOT_ACCEPTED, the exit is offered immediately, the receipt says
never-registered, and joining again on v1 registers, reports and withdraws
with a collector-confirmed deletion.
The dialog described what is sent and where it goes, but never said which way
the connection runs. That is the part an operator is actually being asked to
accept: opening an outbound path to someone else's service.
PicPeak sends and never pulls. One place in the service reaches the network,
it is a POST, and it requests exactly two paths — /api/envelopes, and
/api/participant/lookup only when an operator asks for their own export. No
scheduled job contacts the collector; the daily rollup is driven solely by an
authenticated admin hitting /activity. There is no route the collector could
call, and redirect: 'error' means it cannot even point a request somewhere
else. From a reply only the acknowledgement for the packet just sent is read,
with every field compared against that packet before it is accepted; the
stored copy drops the session token and no read path hands it back to the UI.
A requested export is streamed to the operator as a file and never
interpreted.
The consequence is why it belongs in the consent text and not only in the
docs: this channel cannot deliver code, configuration or content into an
installation, not even from a collector that has been taken over. It is a
security property by design rather than by convention.
usageOutboundOnly.test.js guards it by source inspection rather than
behaviour, because a behavioural test only proves that today's calls behave.
It fails the moment someone adds a second fetch, a poll for messages, a
scheduled pull, or a public route touching the usage service — verified by
injecting each of those.
A QA exploration of this branch against an isolated rig — own stub
collector, SQLite and PostgreSQL — turned up one dead end and a set of
signals and controls that did not hold up. This closes all of them.
Rotating JWT_SECRET, the documented response to a suspected compromise,
made the signing key unreadable. That was already named and documented,
but it left no way out: the delete packet can never be signed, so the
row stays deletion_pending forever, and enable() refuses because it is
not `disabled`. An operator who rotated precisely because the secret was
compromised cannot restore it, so the feature was bricked with no
control left. POST /usage/abandon is offered only in that state; it
drops the local identity and records the receipt as
`collector-unconfirmed` rather than claiming a deletion that did not
happen.
Every failed delivery was retried on the next admin request, and
/activity is open to any authenticated admin while the settings ticker
fires it every five minutes per open tab — 30 activity calls against a
rejecting collector produced 30 outbound requests. Migration 206 adds
attempts/next_attempt_at and the unattended sender honours the gate;
Retry and opt-out still send immediately, and the tab names the time of
the next automatic attempt.
Feedback, votes and portal sessions now share an installation-wide
budget of 30/hour. They are the only endpoints whose effect is outbound
traffic carrying operator-written free text, and the general limiter
skips authenticated requests by design. Reading status and withdrawing
stay unthrottled.
gallery_image_protection was true on a bare install with no galleries:
PicPeak ships default_protection_level='standard' and
enable_devtools_protection=true, so it reported fleet-wide 100% and
could never separate a decision from an untouched default. It now reads
only what deviates from the shipped defaults, and the devtools flag is
not read at all — being on by default, its only informative state is
off, which is the opposite of what the key claims.
Also:
- the export receipt counted every packet and called the total "usage
reports"; reports and participant operations are now counted and named
separately
- GET /usage/preview no longer persists the custom_css marker, so the
transparency view stops changing what will be sent
- the feedback route requires every field the packet schema requires,
so an API caller gets the missing field named instead of a bare
INVALID_PACKET from inside signing
- the German strings for this feature use "Sie" throughout, matching the
rest of the admin UI; the ignore hint says what ignoring will do
rather than stating it as already true
- the consent dialog returns focus to the control that opened it
- the long buttons wrap instead of running off a 390px viewport
- a deletion receipt is labelled as belonging to an earlier
participation while a new one is active
Regression tests cover each of these, including the delete packet's
reuse of the last accepted sequence, which was an unwritten assumption
about the collector rather than a defect.
#1298 and #1303 merged together. #1298 taught the creation paths to
resolve a fragmentation_level default; #1303 removed everything that
consumed it. Neither conflicted textually, so main ended up validating
the field on create and update, copying it on duplicate, resolving
default_fragmentation_level for it, and advertising it in the v1 API
docs — for a value nothing reads and a setting the Image Security tab no
longer exposes.
Inert rather than broken, which is exactly why it needed removing on
purpose: dead code that contradicts the PR that just deleted the feature
is how the next reader concludes fragmentation still works.
The events.fragmentation_level column and the app_settings row stay, as
#1303 decided — dropping a column is irreversible and the stored values
are harmless once nothing reads them.
Refs #1300
Three findings, one of them mine and CI-affecting.
The Postgres suite gets its own schema. CI hands every gated suite the
same PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both
picpeakRestorePg and externalRelpathFoldPg drop and recreate `events`
and `app_settings` in it — so the suite I added would have destroyed
their fixtures and vice versa, intermittently. It now creates and drops
its own `usage_pg_test` schema and reaches the tables through
searchPath, which works because the service queries unqualified names.
Verified on a clean database: after the run `public` still holds zero
tables. My first attempt at this silently did not apply — the
replacement anchor had been reformatted by eslint and I printed success
without asserting the match, which is why the first "isolated" claim was
wrong.
Webhook-only installs are no longer counted as SMTP users. With
EMAIL_WEBHOOK_URL and EMAIL_WEBHOOK_SECRET set, adminEmail sends
/email/test through the webhook transport and never touches SMTP (#1225
added that path), but the rule recorded the permanent `smtp` marker
anyway. Gated on the transport that is actually configured.
Activation is written atomically with its acknowledgement. Split across
two updates, a failure or a stop between them left the row
activation_pending with pending_packet already cleared — registered with
the collector, and permanently stuck locally, because tick() has nothing
to retry from there. The register case now sets status in the same write
and is guarded precisely on activation_pending rather than merely "not
withdrawing".
Refs #1110
Two findings from the review of the current head.
Local backups no longer imply S3. markUsed derived an s3_storage marker
from "a backup ran while backup_destination_type is s3" — but the
middleware also counts /database-backup/* and /backup/picpeak/export as
backups, and those write a local file wherever scheduled backups go. So
configuring S3 and downloading a local export reported s3_storage as
USED. The middleware now tells markUsed whether the operation writes to
the configured destination, and only then is the marker derived. A wrong
`true` in this dataset is worse than a missing signal: it is a claim
about an install that nobody can check.
The ProtocolError branch was dead code. adminUsage matched on
`error.name === 'ProtocolError'`, but the class extends Error without
setting `name`, so every instance reports 'Error' — verified — and a
malformed vote or feedback payload fell through to the global handler,
which logs it as an unhandled programming error and answers
INTERNAL_ERROR in production, losing the validation code the caller
needs. Now matched with instanceof. protocol.cjs is byte-identical with
picpeak-usage (diffed against the companion repo), so the fix belongs
here rather than in the class.
An existing assertion needed updating for the new markUsed argument, and
the path split is pinned: /backup/run is destination-driven,
/database-backup/backup and /backup/picpeak/export are not.
Refs #1110
Everything about this feature had been exercised on SQLite only, which is
the engine least likely to show its problems.
Adds __tests__/integration/productUsagePg.test.js, following the gated
pattern the .picpeak restore suites use: it runs the real migrations
201-203 against a real PostgreSQL and covers what SQLite cannot answer.
node-postgres returns bigint as a STRING, and the withdrawal guard
compares `cancel_seq` — a `'1' !== 1` slip there would let an activation
complete after an opt-out, and SQLite, which hands back a number, would
never show it. Booleans are real booleans rather than 0/1, which is what
every `configured` signal in a report is built from. And markUsed takes
SELECT ... FOR UPDATE on this engine only.
Seven cases, all passing against PostgreSQL 15. Removing the
compare-and-swap condition fails the withdrawal case there too, so the
suite has teeth on that engine and not only on SQLite. CI already
provides PICPEAK_PG_TEST_URL, so these run there rather than skipping.
The collector default is harder to lose. An unset, empty or
whitespace-only USAGE_COLLECTOR_URL now falls back to
https://usage.picpeak.app — deployments that template the variable in
(docker-compose writes ${USAGE_COLLECTOR_URL:-...}) can hand over an
empty string, and that has to mean "use the default" rather than "no
collector". A value that is present but malformed is still reported as a
configuration error instead of being silently replaced: quietly
retargeting a self-hoster's collector at ours would send their reports
somewhere they did not choose.
Refs #1110
Two things reported on the reformatted consent modal.
The green box is gone. Setting "what is never included" apart as a
tinted panel broke the rhythm of the sections and read as an arbitrary
highlight rather than emphasis. All six sections are uniform now; the
icon and heading are enough to tell them apart.
The green bars across the disclosure were a focus ring, not a border.
showModal() focuses the first focusable descendant, which since the
reformat was the scrollable region I had given tabIndex={0} — so its
inset ring was drawn for every user the moment the dialog opened, and
because the dialog clips its sides a full-width inset ring appears as
two coloured bars. Focus now goes to the dialog itself, which is also
the better screen-reader behaviour: the title is announced on open, and
the region's ring appears only when someone deliberately tabs to it. It
is a thinner, softer ring for that case. The dialog suppresses its own
ring, since that focus is programmatic rather than keyboard navigation.
The collector shown in the transport sentence was never wrong: it
interpolates the configured collector, and the screenshots showing
http://127.0.0.1:9 were taken on a rig deliberately pointed at a dead
loopback port so they could not reach production. Re-checked with
USAGE_COLLECTOR_URL unset: the sentence reads
https://usage.picpeak.app and both links resolve there.
Refs #1110
It was seven anonymous paragraphs in one scrolling block, with the title
and the buttons scrolling away with them. The scroll container is
keyboard-focusable, and unstyled it drew a default focus ring, so the
disclosure also looked like a giant textarea.
Now: a fixed header carrying the icon, title and purpose; a scroll
region with six labelled sections, each with a small heading and icon so
the thing can be scanned rather than only read; and a fixed footer with
the consent checkbox and the actions, which no longer scroll out of
reach on a short screen. "What is never included" is set apart as a
tinted block, since it is the part that answers the question an operator
actually has. The focus ring is now a deliberate inset ring on a
labelled region, which is correct for keyboard use instead of an
accident that looked like a form field.
Dark mode is fixed as part of this, and it was my own doing: the dialog
used `bg-theme-surface`, which does not follow dark mode, and the
section text I added carries dark: variants. Light surface plus
near-white text is unreadable. The surface is class-driven now —
neutral-800, which is what `.card` resolves to in dark and what the rest
of the admin UI uses. Verified in both themes through the app's own
theme toggle rather than by forcing the class, which is what produced
the misleading half-state the first time I looked.
Six section headings added in EN and DE.
Refs #1110
Three items, one of which explains an error seen in the app.
"The operation could not be completed" could come from a config typo.
status() called collectorUrl() bare, and that throws on a bare hostname,
a path, a query, or http in production. The settings page renders one
generic failure when its status query errors, so a misconfigured
USAGE_COLLECTOR_URL replaced the whole tab with that sentence — no
cause, and no way to read the status or withdraw, because every control
there sits behind that call. The URL is now reported as
collector_error: 'INVALID_COLLECTOR_URL' beside the real state, the tab
says what is wrong and how to fix it, and the links are only rendered
when there is somewhere to point them.
gallery_layouts reported grid for every preset-themed install.
color_theme holds either a theme object or the NAME of a preset — the
theme picker stores names, and eventTypeService seeds them
(`theme_preset: 'corporateTimeline'`). Only reading value.galleryLayout
made masonry, timeline, mosaic and the two gallery presets invisible.
Names now resolve, and an event with no theme of its own resolves
through the global one instead of being counted as grid. Only the
name -> layout mapping is duplicated, not the presets;
frontend/src/types/theme.types.ts stays the source of truth, and an
unknown name reports `other` so a preset added later degrades to
"something else" rather than quietly inflating the grid count.
custom_css missed CSS applied through a template. An enabled
css_templates row applied via events.css_template_id is gallery styling
by the same definition as the settings fields — the Custom CSS tab is
where both are authored — but neither the snapshot nor the middleware
saw it, so those installs reported custom_css entirely false. Existence
only; template contents are never read.
Eleven tests. Reverting each fix in turn fails 3, 1 and 3 of them.
Refs #1110
Third and last window in the same race, and again in my own fix.
locked() claims the lease and reads the row in two separate statements.
Reading the cancellation counter from inside that callback meant a
/disable completing in the gap was adopted as this activation's own
baseline and silently absorbed — the counter matched, the claim
succeeded, and registration went ahead after the operator had withdrawn.
The baseline is now read before the lease is taken, which inverts it:
every increment from that point on is later than the value the claim
tests for, so the claim fails and the withdrawal wins. An increment from
before the read is a withdrawal the operator already completed, and a
deliberate opt-in afterwards should not be vetoed by it.
The test for this passed against the bug on its first two attempts. It
stubbed the state read to increment the counter AFTER reading the row,
so both the broken and the fixed version saw the old value and behaved
identically. The withdrawal has to land before the read returns for the
row to carry it — which is the whole point of the window. It now fails
without the fix.
Refs #1110
Follow-up review on the previous commit, including a hole in that
commit's own fix.
The cancellation flag became a counter. Clearing a boolean needed a
write of its own, and a /disable landing between the lease and that
write was erased — the same race one level down. enable() now records
the counter it started with and claims only if it is unchanged, so no
clearing write exists to lose. It also fixes the case a boolean could
not express at all: a stale cancellation already set, and a fresh one
arriving mid-activation, are indistinguishable as flags and obvious as
counts. Migration 203, separate from 202 for the reason 202 was separate
from 201 — knex will not re-run an applied migration.
deliver() re-checks immediately before dispatch. The existing check ran
before the binding lookup, which is asynchronous, so a withdrawal that
COMPLETED during it still had its registration or report sent
afterwards. Not an already-in-flight request — a new one started after
the operator had withdrawn.
The outbox writes in tick() and command() are conditional on still being
active. /disable clears pending_packet without holding the lease, so an
unconditional write put a report — or a feedback body and name — back
into an outbox the withdrawal had just emptied, where deliver() would
then leave it, since it declines to send anything but the delete.
Per-item name consent resets with the item. `named` stayed checked after
submitting, so the next item carried the previous name automatically,
contradicting the anonymous-by-default promise the disclosure makes for
each item. The remembered name stays in preferences; attaching it is
decided again each time.
Two of these tests were worthless when first written and are noted
because the pattern keeps recurring: the pre-dispatch case passed
without the guard because an empty report payload failed schema
validation during signing, so nothing reached the collector for reasons
unrelated to the check. With a valid payload it fails without the guard
and passes with it. Same for the counter: dropping it from the claim
fails two.
Refs #1110
The last open item from the #1304 review.
/disable overlapping an in-flight /enable was silently lost. While
activation generates its identity and writes its binding file the row
still reads `disabled`, so disable()'s conditional update matched no
rows, and the lease conflict raised by its tick() was swallowed as
expected noise. The admin was told participation was off; the activation
then completed and left it on. An opt-out that does nothing is the one
failure this feature cannot have.
disable() now records cancel_requested first and unconditionally —
before the case-by-case work — and enable() claims its state with a
single conditional UPDATE that tests the flag alongside the status.
Re-reading the flag and then updating would only have moved the window;
making the claim itself carry the condition closes it, so whichever of
the two lands first wins outright and the loser writes nothing.
Nothing is registered when the claim fails, so there is also nothing to
delete remotely — the cancelled activation leaves no identity behind.
The flag is cleared at the start of enable(), so a cancellation from an
earlier participation cannot veto a later deliberate opt-in.
The column is migration 202 rather than an edit to 201. 201 already
shipped on this branch and knex records it as applied, so folding the
column in would have skipped every database that had already run it and
the first /disable would have failed on a missing column. Verified both
ways: a fresh install gets the column from 201+202, and a database
migrated before 202 existed gains it when 202 arrives.
Three tests. With the condition dropped from the claim, the race case
fails and the other two pass.
Refs #1110
It appears on the dashboard and settings only. It is an invitation, not
an alert, so it belongs on pages an admin opens deliberately rather than
on top of whatever task they are in the middle of.
The activity ticker deliberately did NOT move with it. That ticker is
what triggers the daily rollup — the backend has no scheduler — so
tying it to the banner would have stopped reporting for an admin who
works on Events and never opens the dashboard, and stopped it entirely
for a participating install, where the banner never renders at all. The
effect stays mounted on every admin page and only the visible aside is
scoped. Two tests pin exactly that, because it is the kind of thing a
later refactor would helpfully "clean up".
Highlighted like the migration banner it sits under: tinted surface,
border, icon, a title line above the body. It was previously the same
neutral surface as the page behind it and read as filler.
"Not now" is now "Ignore". The button calls dismiss(), which persists
notice_dismissed on the server — the invitation never comes back. "Not
now" promised otherwise. The label says what happens and a hint says
where to join later.
Only shown while participation is off. activation_pending,
deletion_pending and identity_conflict are in-flight states the settings
page explains properly; inviting someone to join in the middle of their
own withdrawal would be worse than saying nothing.
The first version of these tests was worthless: the negative cases
asserted absence after waiting only for the status call, so the
component was still rendering null for want of data and every one passed
with the gates removed. They now wait for the query cache to fill.
Removing the route gate fails 4; removing the status gate fails 6.
Refs #1110
Three of four findings from the follow-up review.
A withdrawal is no longer clobbered by the instance-copy check. That
update was unconditional, so an opt-out arriving while the binding
lookup was in flight was replaced by identity_conflict — and tick()
stops there, so the deletion the operator asked for was never sent. It
now carries the same whereNot('deletion_pending') guard the
collector-conflict handler beside it already had.
Scheduled database backups count as a configured backup. The middleware
records /backup/* and /database-backup/* under one capability, but
`configured` read only backup_enabled, so an install whose only backup
is the scheduled database one reported used: true, configured: false —
a contradiction in the dataset this feature exists to produce.
SIGNING_KEY_UNREADABLE gets its own message. Naming the error in the
previous commit was half the job: the settings page still showed the
generic retry/disable advice, and neither action can succeed without the
original encryption material. It now says what happened and what is
actually required, in EN and DE.
NOT fixed, and reported instead: /disable overlapping an in-flight
/enable. While activation is still doing its slow work the row still
reads `disabled`, so disable's conditional update matches nothing and
the lease conflict from its tick() is swallowed — the operator is told
participation is off while activation completes and leaves it on.
Closing it properly needs a persisted cancellation flag that enable
checks before finalising: taking the lease cannot help, since it either
conflicts immediately or would block the request for the 60s lease. That
is a schema and state-machine decision for the author, not something to
restructure underneath them.
Refs #1110
Review follow-ups on #1304.
SIGNING_KEY_UNREADABLE. USAGE_ENCRYPTION_KEY defaults to JWT_SECRET, so
rotating JWT_SECRET — the correct response to a suspected compromise —
makes the stored Ed25519 key undecryptable. That surfaced as a generic
DELIVERY_FAILED which retried forever, and it silently blocks the DELETE
packet too: an operator who withdraws has their local state cleared
while the collector keeps its copy. decrypt() now tags its own failure
and deliver() reports it under its own name, without flagging an
identity conflict — an unreadable key is not evidence of a clone. The
docs already warned that losing the key breaks deletion signing; they
now name the trigger and the error.
The collector default is no longer an inline string in the constructor.
It is a declared DEFAULT_COLLECTOR_URL, since it is a deployment choice:
self-hosters point USAGE_COLLECTOR_URL at their own collector and the UI
already derives every link from whatever is configured. schema.cjs is
deliberately untouched — it is vendored byte-identical with
picpeak-usage, and its $id is a schema identity, not a delivery address.
Links in the consent dialog. It named the collector inside prose but
never linked it, so an operator deciding whether to opt in could not
open the destination or the public schema without retyping a URL. Both
are links now, built from the configured collector.
UI standards. The tab hand-rolled its surfaces as
`<section className="rounded-xl border border-theme …">` and imported
Button from a deep path; every other settings tab uses `<Card
padding="md">` from the components/common barrel. Converted, with the
feedback <form> wrapped rather than replaced so its semantics survive,
and headings given the same colour tokens as ImageSecurityTab. The
barrel pulls ErrorBoundary -> i18n/config, so the tab's test needed the
initReactI18next shim the FaceRecognitionCard test already uses.
Not changed: the delete packet reusing the current sequence. The
collector handles delete before any sequence check — "possession proof
is sufficient for deletion, including when a restored backup has a
stale sequence" (picpeak-usage server/collector.js) — so deletion is
deliberately sequence-exempt and the client is correct as written.
Refs #1110
Fifth bypass, and the same root cause as the first: sanitizeCSS
validated, then kept rewriting.
`<[^>]*>` deletes the span it matches, and `<">` takes a quote with it.
So `--x:x<">;background:url(https://evil.example/p.gif);--y:x<">` was
scanned with the url() safely inside a string, and the tag strip below
then removed the quotes that made it so — shipping a live remote
background with no warning.
The file already carried the rule: "any pass that can join tokens has to
happen before validation, not after." It has now been broken three
separate times — by the HTML-comment strip (#1290), the control-
character strip, and the tag strip. Rather than fix a third instance in
place, the URL scan is now the LAST step, so what is validated is always
the bytes that get served.
All eight known bypass classes are pinned, together with the legitimate
data: URI, quoted font stack and escaped selector that must survive
untouched.
Refs #1264
The fourth bypass found in this review, and the one no lexer fix
reaches: sanitizing runs on the stored body, but safeTemplateReplace
rewrites it afterwards, so the string that was validated is not the
string that is sent.
A conditional inside a style attribute can delete the very quoting that
made a url() inert:
style="--x:x{{#if company_name}}'{{/if}};background:url(https://evil…)"
At write time the url() genuinely sits inside a CSS string and is
correctly left alone. Expanding the conditional for a recipient with no
company name removes both quotes and the background goes live —
confirmed end to end against the real functions.
The style-attribute pass now runs again on the substituted output.
Substitution cannot introduce a `"` (values are HTML-escaped), so the
attribute match still holds. body_css is not substituted, so the
<style> block cannot be rewritten after its check and needs nothing.
This is the case the removed newsletter pass had been covering. Rather
than reinstating a second definition of "disallowed", the one definition
now runs at both points where the content changes.
Refs #1264
Third bypass of this scanner found in one review pass, and the same
shape as the others: the lexer and a browser disagreeing about where a
token begins.
JavaScript's `\s` matches U+00A0; CSS whitespace is exactly space, tab,
LF, CR and FF. Skipping an NBSP as whitespace let the scanner read the
quote after it as a legitimate quoted data: URI and swallow a remote
url() inside that "string" —
.a{background:url(<NBSP>"data:image/png);background:url(https://evil…);--x:");}
came through untouched, with no warning, and survived re-sanitising. A
browser treats NBSP as an ordinary character, so that is an UNQUOTED
url-token ending at the first `)`, leaving the remote background live.
All three token readers now use an explicit CSS whitespace class.
Ordinary spacing around a data: URI still works, and is pinned.
Refs #1264
My previous commit introduced this. Handling `\` outside strings before
readIdentifier meant a LEADING escape was eaten before the url check
saw it: `\75` is the CSS escape for `u`, so `.a{background:\75rl(...)}`
is url() to a browser and passed through untouched, with no warning —
a bypass the base version did not have. An escape mid-identifier
(`u\72l`) was unaffected, which is why the first tests missed it.
The escape branch now runs AFTER readIdentifier, which already decodes
leading escapes itself. What is left for it is the case it was added
for: `\'`, which must not be read as opening a string.
Both spellings are pinned, along with the legitimate escaped selector
and data: URI that must survive untouched.
Refs #1264
Two review findings, both about the warning saying things that are not
true.
The duration contradicted the rate beside it. `rate` came from the live
draft while `minutes` came from the resolution the server computed from
the SAVED rate, so any unsaved edit produced a mismatched pair: 120
recipients switched from 10/min to 1/min still claimed 12 minutes rather
than 120. Recomputed from the rate the send will actually use — queueing
persists the draft first — with the server's own formula and the same
1..10 clamp, so the two numbers cannot disagree.
The queue claim was overstated. "other email queues behind it" is only
true when the campaign saturates the queue: scheduled_at is staggered
and processEmailQueue excludes future rows before taking its batch, so a
campaign paced below the 10/minute ceiling leaves capacity for a
password reset on the next tick. Now says other email CAN be delayed
behind it, which is what the shared queue actually guarantees.
The new test fails against the previous pairing.
Both found by review against the correct base, and both are cases the
second stripRemoteCssUrls pass had been catching before this PR removed
it. Verified against the real functions before and after.
An escaped quote outside a string. `\'` is an escaped identifier
character, not a string opener, but the scanner stepped onto the
apostrophe, entered string mode and copied the rest of the stylesheet
unexamined — so `.hero{--marker:\';background:url(https://evil/p.gif)}`
kept a live remote URL. Escapes are now consumed as a unit outside
strings.
An unterminated quote. Trusting one meant a single stray apostrophe
disabled scanning for everything after it. An unclosed quote is a parse
error, so the safe reading is to emit it as an ordinary character and
keep scanning; a newline also ends a string, as it does in CSS.
The entity mismatch behind the second case. sanitize-html writes `"`
inside an attribute as `"`, so the scanner and the recipient's
browser disagreed about where strings begin: in
`style="font-family:"don't";background:url(...)"` the browser
decodes first, reads the apostrophe as ordinary text inside a real
string, and fetches the background — a tracking pixel by another name.
Style attributes are now decoded before scanning and re-encoded after,
which also stops the old code silently deleting quotes from the value.
Also detaches the image handlers before releasing the canvas source.
That one did NOT reproduce: measured in both Chromium and WebKit,
neither fires `error` when the attribute is removed after a successful
load. Applied anyway because the ordering is free and the failure it
would cause is silent — canvasFailed set, the canvas swapped for an
<img>, and the image decoded a second time, the exact opposite of what
the release is for.
Refs #1264, #1287
Closes#1300.
Fragmentation was configurable, stored per event, served to the gallery
client, and consumed by nothing. It was not unbuilt scaffolding — both
halves exist and are individually coherent — but they were never
connected, and they disagree: the server cut a fixed 3x3 grid while the
client reassembled a 4x4 one, so wiring them together as they stood
would have produced scrambled images rather than protection.
Removed rather than finished, because finishing it buys nothing. The
client fetches the whole image and then redraws it in pieces on a
canvas, so the full original has already crossed the wire before any
"protection" is applied — that is obfuscation, not a control. The
per-fragment canvas work also lands on mobile, which is the memory
profile under investigation in #1287.
Goes: secureImageService.fragmentImageBuffer and its branch, the
?fragment=N delivery path and handleFragmentedImage in secureImages,
the fragmented-JSON response in protectedImages, fragmentation_level in
the gallery payload, the default_fragmentation_level setting, the PUT
validator, the ProtectedImage fragment renderer, and the operator
control with its strings in all eight locales.
No migration. `events.fragmentation_level` and the app_settings row stay
— dropping a column is irreversible and the stored values are harmless
once nothing reads them. If they should go, that is a deliberate data
decision and its own migration.
`fragmentGrid` on AuthenticatedImage and the layouts is deliberately
untouched: #1299 already removes it as part of the inert prop surface,
and doing it here would only collide.
Replaces the six per-field .not().isArray() guards from the previous
commit. Those were too narrow, and arbitrarily so.
PUT /:id spreads req.body into `updates` (crud.js:1631) and passes it to
.update() (:1990) with only targeted deletes in between — there is no
column allow-list. express-validator applies isInt/isIn/isBoolean
element-wise to arrays, so a single-element array satisfies its field
validator and survives the whole way to the column. That is true of all
44 validated fields, not of the protection block I happened to be
looking at; seven of them also run through formatBoolean, where [false]
reads as true.
So the guard belongs where the body is spread, not on chosen fields.
`customer_account_ids` is the only field legitimately an array — it has
an isArray() validator and its own element rules — and it is deleted
from `updates` before the write, so exempting it costs nothing.
Tested across the protection fields and two outside that block, plus the
customer_account_ids exemption. With the guard's condition disabled,
exactly those six array cases fail and the other 15 in the suite pass.
Refs #1296
The composer already paces sends — the rate is clamped to what the queue
can actually drain and `scheduled_at` is staggered — and the help text
said so. But pacing answers the wrong risk. Spam filtering reacts to a
domain's volume and reputation, not to the interval between messages, so
a throttled send of several hundred near-identical mails from a domain
that normally emits only gallery notifications is exactly the shape that
gets junked or blocked. Nothing told the operator that.
Adds a warning above the queue button once the resolved recipient count
reaches 50, covering what actually goes wrong: the reputation hit, that
it damages delivery of transactional email too, the SPF/DKIM/DMARC
prerequisite, and the suggestion to split a first campaign.
It also states the cost of the send in the operator's terms — the real
duration at the chosen rate, and that gallery invitations and password
resets queue behind it, because the email queue is global rather than
per-campaign.
50 is deliberately low: the operators who most need this are the ones
sending their first campaign.
The existing rate hint now mentions looking like spam, not only being
rate-limited.
PUT /:id has the same weakness the create chains just had:
express-validator runs isIn/isBoolean/isInt element-wise, so
`image_quality: [72]` satisfies every check and stays an array. This
handler spreads req.body straight into the update, so the array reached
a scalar column — a PG insert error, and `[false]` read as true.
Covers all six fields in that block, not only the four this PR is about.
enable_devtools_protection and overlay_protection sit in the same list
with the identical flaw, and leaving two known holes next to four closed
ones would have been the odd choice.
Refs #1296
Round-four review follow-ups.
Every reader of app_settings now shares decodeSettingValue. The previous
commit taught the GET handler to decode, which on a legacy SQLite install
made the tab show devtools protection as disabled while
readBooleanSetting — parsing once, getting the string 'false', rejecting
it — left new galleries with it enabled. A decoder used by only some
readers is worse than none, because the UI and the behaviour disagree.
readBooleanSetting, getImageSecurityDefaults, the v1 devtools fallback
and the settings GET all use it now.
Standalone contract conversion covered. contract/conversions.js takes
Path B and inserts its own event row when the contract has no source
quote, so signed standalone contracts were the last path still landing
on the migration-038 column defaults.
Refs #1296
Round-three review follow-ups.
getImageSecurityDefaults now accepts a transaction, the way getAppSetting
two lines above it already does. quoteService.convertToEvent called it
from inside db.transaction() through the global db; sqlite3 runs a
single-connection pool, so that read would have waited on the connection
its own transaction was holding until the acquire timeout, and the
helper's catch would then have swallowed the error and dropped the
defaults silently.
The double-encoding is fixed where it starts. GET
/admin/image-security/settings returned setting_value undecoded, so it
shipped "true" to a tab that types the field as boolean — and since the
tab PUTs the whole object back through JSON.stringify, every save
wrapped another layer around values nobody edited. It decodes now, so a
round trip is idempotent. The tab is the only consumer of that endpoint.
The reader unwraps to any depth instead of four. The depth on an
existing install is however many times someone opened that tab, which is
not a number to cap. It terminates because each parse of a string is
strictly shorter than its input.
Refs #1296
Round-two review follow-ups.
Settings survive the tab round trip. GET returns setting_value without
decoding it and ImageSecurityTab PUTs the whole fetched object back
through JSON.stringify, so on SQLite one visit to the tab re-encodes
every value it read. A single parse then yields the string "true", the
type checks reject it, and the defaults go quietly dead — the exact bug
this change exists to fix, returning by a different route. The reader
now unwraps until the value stops being a JSON string, bounded.
Array overrides rejected. express-validator applies isInt/isIn/isBoolean
element-wise, so `image_quality: [72]` passed the chain and arrived
still an array — a PG insert error, and `[false]` coerced to true by
formatBoolean. Both create routes now use .not().isArray(), and the
shared resolver ignores non-scalars for any future caller.
Two more creation paths covered. quoteService.convertToEvent builds its
own events row, so CRM-converted galleries fell back to column defaults.
/:id/duplicate copies fifteen source columns including
enable_devtools_protection but missed these four, so duplicating a
'maximum' gallery produced a 'standard' one — a duplicate now inherits
the source's values, not the current globals, since copying the gallery
is the point.
The PUT /:id chain has the same array weakness. Pre-existing and outside
this fix; left alone deliberately.
Refs #1296
Review follow-up on the sanitizer dedup.
sanitizeCSS already carried the rule — "any pass that can join tokens has
to happen before validation, not after" — written above the URL scan to
explain why it runs after the HTML-comment strip. The control-character
strip is exactly such a pass and sat eleven lines below it.
So `u<CTRL>rl(https://tracker.example/p.gif)` was scanned as clean, and
the strip below then joined it into a live remote request with no
warning. Newlines are control characters here too, so `u\nrl(...)` did
it without an exotic byte. Verified against the real function before and
after: all five variants returned a live remote url() and now return
`none` plus the blocked-URL warning.
This PR is what exposed it. Dropping newsletterService's second
stripRemoteCssUrls pass was right — the duplicate hid a defect in the
shared sanitizer rather than fixing it — but it removed the belt that
was catching this for the newsletter path. Fixing the ordering fixes it
for every caller instead of restoring the second pass.
Refs #1264
Review follow-up. The release only ran from the effect cleanup, so it
fired on unmount or a src change — while the commit message and the test
header both explained that grid tiles never unmount, which is the whole
reason the decode piles up. For the case the change exists for, it never
ran at all.
drawToCanvas now reports whether it drew, and the source Image is
released as soon as the pixels are on the canvas. Nothing redraws from
imageRef afterwards; drawToCanvas has exactly one caller. The cleanup
stays as the fallback for the paths onload cannot cover: the draw
failed, or the source changed before onload fired.
The new test pins release while still mounted, on the same src. It fails
against the previous version.
Refs #1287
Review follow-ups on the #1296 fix.
The defaults were resolved only in the admin POST / handler. POST
/api/v1/events builds its own insert and resolved just the devtools
setting, so an API-created gallery still fell back to the column
defaults — the same split that made #592 a separate bug from #317, about
to be repeated. Both paths now share resolveImageSecurityColumns().
An explicitly supplied value now wins over the global default. The
create routes never accepted these four fields at all, though PUT /:id
has validated them all along, so a client sending protection_level on
create had it silently dropped. The previous comment claimed the spread
ordering preserved a request value; there was no request value to
preserve, and a later spread would have overridden one anyway.
Settings validation no longer leans on parseInt, which rescues '72oops',
72.5 and [72] into valid-looking integers. The settings PUT stores
whatever JSON it is handed without validating values, so those really
can reach the resolver.
fragmentation_level is still stored and consumed by no renderer —
ProtectedImage hardcodes a 4-grid and secureImageService a 3x3. Noted in
the API docs rather than silently implied to work.
Refs #1296
AuthenticatedImage accepted the whole image-protection prop surface and
discarded it in a `void unusedProps` block. Callers computed those props
from the event's protection level and passed them in good faith, so
raising the level produced canvas rendering (via the layouts' own OR on
`protectionLevel === 'maximum'`) and nothing else the level implies.
Removes them from the interface and from every call site, so the props
state what the component actually does. Two survive because they are
real: `useCanvasRendering`, and `onProtectionViolation` — which #1297
listed as inert but which does fire, from the canvas context-menu
handler. `useWatermark` is removed as well; #1297 did not list it (it sat
outside the `unusedProps` block) but it was equally dead.
Removal rather than implementation is deliberate. The implementation
these props describe already exists in `ProtectedImage`, which is
exported from the barrel and rendered nowhere. Wiring it in is a product
decision about what protection level should mean, not a side effect of a
cleanup.
Analytics payloads inside the surviving onProtectionViolation handlers
keep their photoId/protectionLevel fields.
Refs #1297
Four controls in Settings → Image security were written, reloaded and
rendered as toggles, and read by nothing:
default_protection_level → events.protection_level
default_image_quality → events.image_quality
enable_canvas_rendering → events.use_canvas_rendering
default_fragmentation_level → events.fragmentation_level
Each maps onto a column migration 038 already created, and each is
labelled "… by default". `enable_devtools_protection` was the only one of
the five ever wired (#317), and its plumbing is the pattern this follows.
Reported for enable_canvas_rendering by @leonlivevocalist-svg while
instrumenting #1287 — the setting was globally true on their install and
zero canvas elements were created. Checking the neighbours found three
more of the same, so fixing one and leaving three would have been worse
than leaving all four.
CREATION-TIME ONLY, deliberately. Applying these to existing events would
silently change live galleries on upgrade: an install with
enable_canvas_rendering already on would flip every grid to canvas
rendering, which is memory-expensive at scale and is the exact profile
under investigation in #1287. New events inherit; existing rows are
untouched.
A missing or malformed value yields no key, so creation falls through to
the column default exactly as before — including the ranges, where an
out-of-range quality or fragmentation level is ignored rather than
clamped into something the operator did not choose. `false` is carried
through rather than dropped as falsy, or "off" would be unreachable.
The spread sits after the explicit columns so a value supplied by the
request still wins.
12 tests: the mapping, the false case, seven malformed inputs falling
through, partial configuration, and that a settings failure cannot block
event creation.
Grid was the only layout passing `lazy` without an `inViewRootMargin`, so
PhotoCard ran its observer at the IntersectionObserver default of `0px`
with `threshold: 0.1`. A tile could not begin loading until a tenth of it
was already on screen — there was no lead at all.
The gallery owner's account of the symptom is that defect's exact shape:
spinning the scroll wheel outran loading by roughly 50 images, then it
caught up. Outrun-then-recover is what a zero-width pre-load band looks
like from a chair.
This is the one thing in that investigation that does not rest on the
reporter's instrumented runs, which they have since withdrawn after
finding their automation harness ran in a hidden pane — `innerHeight: 0`,
so nothing could intersect and no tile could ever load. The missing
margin is visible in the source regardless.
Percent, not vh. `rootMargin` accepts only px and percentages, and a `vh`
value throws SyntaxError at construction, which would have taken down
every Grid gallery. Verified in Chrome:
'100% 0px' → accepted
'100px 0px' → accepted
'100vh 0px' → SyntaxError: rootMargin must be specified in pixels or percent
A percentage resolves against the root's own box, so 100% is one viewport
height of lead in each direction — viewport-relative, which a fixed 100px
like Justified's is not. A phone and a 4K desktop scroll past very
different amounts of grid per gesture.
Deliberately NOT included: a sweep for cards left un-loaded after
scrolling settles. That was aimed at permanent loss from `triggerOnce`,
and the owner's observation that tiles do come back on desktop argues
against it. Complexity chasing a symptom nobody has reproduced outside a
broken harness.
Three guard tests, including one on the unit, since the failure mode of
getting that wrong is a gallery that does not render at all.
Two follow-ups to yesterday's merges. Both were already known; neither
depends on the open question in #1287.
1. Canvas mode pinned every decoded image for the component's lifetime.
`AuthenticatedImage` keeps a detached Image in `imageRef` so drawToCanvas
can read it. The effect cleanup nulled onload/onerror and never cleared
that ref, so the Image — and the decode behind it — stayed held by a live
JS reference. A decoded <img> in the document is evictable under memory
pressure; one held by a ref is not.
That is not academic at gallery scale. The photo grid is NOT virtualised,
so a 546-photo event mounts 546 of these and none ever unmount — nothing
was ever released. The ref is cleared and the src dropped, so the browser
can reclaim without waiting for GC.
This is NOT presented as the fix for #1287. That investigation is still
open: the reporter has since shown the backend idle during a stall and
the renderer itself unresponsive for 45s, which rules out the theories
tried so far. This is a real leak on the same path, worth fixing on its
own terms while that question is settled.
2. newsletterService no longer carries its own remote-url() stripper.
It was added because the shared sanitizeCSS "blocked" remote URLs with a
CSS comment that parsers discard. #1290 replaced that with a lexer, so
the local copy is dead weight — and two definitions of "disallowed" would
drift apart. Verified the shared function covers every case the local one
did, including the quoted-paren and CSS-escape forms found in review.
Three tests on the release path, two of which fail without the fix:
unmount clears the ref and drops the src, the blob URL is revoked, and a
src change releases the previous image rather than accumulating one
pinned decode per photo a recycled tile has shown.
Part B of #1264. Flag off by default, so an install that never enables it
gains no route, no nav entry and no way to mass-mail.
A campaign is a body plus a recipient rule. Queueing one writes ordinary
email_queue rows (email_type 'newsletter', origin 'campaign', new
campaign_id), so retry, rendered_html, sent_at and error_message all come
from the existing processor rather than a parallel sender. Throttling
staggers scheduled_at; the processor loop is untouched.
Two rules the service enforces: no raw HTML is ever stored (sanitized on
write and again on render, idempotently), and opt-out is checked at queue
time AND again at send time.
Migration 199 adds email_campaigns, email_campaign_recipients,
email_queue.campaign_id, customer_accounts.marketing_opt_out(_at), and
the newsletters.view / newsletters.send permissions.
Three rounds of external review are folded in, including several that
would otherwise have shipped broken:
- Campaign rows never came due on SQLite. queueEmail writes a Date, which
the sqlite3 binding stores as epoch ms; ISO text in the same column
compares as TEXT against an INTEGER, and SQLite orders every INTEGER
below every TEXT. The feature silently sent nothing there.
- The flag had no Settings card and no sidebar entry, so it could not be
enabled through the UI at all.
- Consent is per ADDRESS, not per row: two accounts sharing an inbox meant
unsubscribing stopped one and not the other, at both queue and send time.
- The unsubscribe GET mutated consent, so a mail-security scanner walking
a campaign could have unsubscribed much of the list. GET now confirms,
POST acts.
- The rate ceiling is clamped to the queue's real throughput (10/min), so
the composer's estimate stops being wrong by up to 12x.
Closes#1264
sanitizeCSS "blocked" a remote url() by prefixing it with a
/* BLOCKED URL */ COMMENT and leaving the URL in place. CSS comments are
discarded during tokenization, so the declaration a browser parsed still
carried the live URL — while adminCssTemplates returned
sanitization_warnings claiming it had been stopped. Protection that
reports success is worse than none, which is why it survived review.
Scope is narrow: sanitizeCss (lowercase, the public-site path) never
included the pattern and permits remote URLs by design — a test now pins
that. Only sanitizeCSS (uppercase) was affected; outside this repo's
newsletter branch its sole caller is adminCssTemplates.js.
Migration 200 is required, not cosmetic: gallery.js serves
css_templates.css_content VERBATIM as text/css and does not re-sanitize on
read, so fixing the write path alone would leave every existing template
serving its URL forever.
Review follow-ups replaced the regex with a small three-state lexer
(comment / string / identifier) over the RAW text, after five further
bypasses: a ")" inside a quoted url(), CSS escapes (u\72l), the HTML
comment strip JOINING tokens into a live url() after the scan, an escaped
quote desynchronising the scan, and a quote inside a comment. Escapes are
decoded only to decide, never to rewrite — a clean input now round-trips
byte-identical, which also keeps unaffected rows out of the migration's
write path.
Severity is low (writing a template needs branding.edit) but the harm is
a gallery visitor's IP reaching a third party from a page the operator
believes carries no remote requests.
tailwind.config.js had plugins: [] and @tailwindcss/typography was never
installed, so every prose / prose-neutral / dark:prose-invert class in the
app resolved to nothing. Preflight, which IS active, resets h1-h6 to
inherit size and weight and strips list-style from ul/ol — so an applied
<h2> rendered pixel-identical to the <p> it replaced.
The editor was never broken. The toolbar highlighted because
editor.isActive('heading') correctly returned true; only the CSS to show
it was missing. That also explains why pasting rendered rich text worked:
it carries inline styles.
Ten surfaces rely on these classes, including the PUBLIC CMS pages — so
impressum/datenschutz were serving unstyled headings to visitors too.
Review follow-ups: prose colours are mapped to the theme tokens wherever
.text-theme marks theme-owned text (a dark gallery preset sets
--color-text but no .dark class, so dark:prose-invert never engages and
headings would have gone near-black on dark); code blocks inherit rather
than being scaled twice; and H5/H6 get explicit rules, since the plugin
only styles h1-h4.
Closes#1288
Hardening for the large-gallery stall. The reporter could not isolate the
cause and neither could I from static reading; these are two defects that
are wrong independently of whether they are the whole story.
Gallery grids are NOT virtualized: a 546-photo event puts 546 PhotoCards
in the DOM, each mounting its own bare fetch. Two problems there: no cap,
and a cleanup that only set a flag while the request kept running.
withImageFetchSlot now holds requests to six in flight, with the BODY read
inside the slot — fetch resolves on headers, so releasing there would have
bounded header round-trips and nothing else. Teardown aborts via
AbortController.
A request queued indefinitely is PENDING, not failed, which is why the
failure left no console error, no failed request and nothing in the
backend log.
Review follow-ups: three tiers (current lightbox slide > neighbour
prefetch > grid thumbnails), because a single FIFO put the image the user
just clicked behind hundreds of thumbnails; and a synchronous throw now
releases its slot instead of permanently draining the pool.
If it recurs, capture performance.getEntriesByType('resource') for the
stalled thumbnails — a queued request shows responseStart === 0.
Relates to #1287
show_feedback_to_guests means "don't show guests OTHER PEOPLE's feedback".
The per-viewer is_liked flag was gated on it anyway, so turning sharing
off emptied every heart the guest had set themselves, on every page load,
while the photo_feedback rows sat there intact.
The query behind the flag is filtered to the viewer (by guest_id, or by
their own IP+UA identifier), so what it returns was never aggregate data.
The colour-label block twelve lines below already documents this exact
reasoning and is correctly ungated.
Scope is just that flag — the counts beside it stay gated, with a test
pinning that the fix does not leak them back. The #1150 contract still
holds: an admin-hidden like does not read as liked.
Note for the reporter: the FILTER path was already correct
(includeGuestMatches is ungated, /my-feedback carries no gate). The empty
Likes chip was downstream of the same falsified flag, not a second bug.
Closes#1286
The business profile already carried the operator's full issuer block —
address, phone, email, website, VAT id — but none of it reached an email.
Those columns only fed the quote/invoice PDF renderer, so every outgoing
mail footer was the fixed logo + company name + copyright line.
The signature is rendered by wrapEmailHtml and nowhere else, so no
template, no per-type send path and no queue row needed a change. Two new
columns on business_profile (migration 198) carry the toggle and one
free-text legal line; everything else is read from the address fields the
operator already maintains.
Default off, with a test pinning that the disabled path is byte-identical
to a no-profile install.
Includes three rounds of external review fixes: the plain-text MIME part
also carries the signature; string booleans ('false'/'0') no longer
invert the toggle; the status line stays silent rather than asserting
"off" while unauthorised or loading; and the preview's Text tab mirrors
the send path's htmlToText fallback.
Manual Messages replies deliberately keep no signature — they bypass the
wrapper by design — and the UI copy names that exception.
Closes#1264 (Part A)
Adds docs/upload-file-types.md: the single Allowed File Types setting, every
path it governs, the extension-to-MIME table, how to enable video, and what
changed for chunked-upload/init (declared mimeType ignored, extension must
be allowed, 400 File type not allowed). README links to it, and the Settings
help text in EN and DE now says the list covers all upload paths and that
video extensions must be added explicitly.
backend: qs and body-parser (array-limit bypass, isBuffer DoS).
frontend: axios 1.17 line (formToJSON recursion DoS, prototype-pollution
gadgets, maxBodyLength bypasses), dompurify, linkify-it and the transitive
set npm audit fix resolves without a major bump.
Left out on purpose: sanitize-html 2.17.7 (its htmlparser2 12 tree is
ESM-only, which Jest 29 cannot load, and the SVG SMIL advisory needs svg
tags none of our sanitizer configs allow), and the tiptap 2->3 and
react-router 6->7 majors (open redirect via <Link> needs a user-controlled
navigation target, which the SPA has none of).
Keeps the size error first, as before the allow-list landed, and pins the
allow-list gate in the size-limit suite: a .html filename is refused
whatever MIME the client declares.
- the customer contract PDF stream applies assertContractPdfPath like the
admin and public contract routes
- OG previews fall back to the site card for draft, archived and
deactivated galleries instead of leaking name, date and welcome message
- video Range requests are validated before the 206 is written; a NaN,
inverted or out-of-file range now answers 416
- share-token comparisons in gallery resolve/info use the constant-time
helper share-login already used
- middleware/photoAuth.js and the galleryAuth/photoAuth/verifyGalleryAccess
exports of middleware/auth.js were unreferenced since the static mounts
went; the auth.js copy had neither slug binding nor issuer pin, so it is
removed before anyone mounts it
safeValidationErrors moves to utils/routeHelpers and replaces every
res.status(400).json({ errors: errors.array() }) in the routes, so no 400
body carries the submitted value any more (setup, customer auth and
customer change-password were still echoing rejected passwords).
Admin login, gallery verify, customer login/register/reset, customer
change-password and setup now cap username/slug at 255 and passwords at
MAX_PASSWORD_LENGTH at the validator, so an oversized value never reaches
the lockout lookup, bcrypt or the failed-attempt log.
Admin and customer login run one bcrypt compare on every path; the unknown
account branch used to return in microseconds against ~100ms for a wrong
password, which enumerated usernames despite the generic message.
- maintenance mode classified paths case-sensitively while Express routes
case-insensitively, so /API/... walked past the gate
- the general rate limiter skipped anyone holding any verified JWT; a
gallery token is minted for free on password-less galleries and slideshow
links, so that was an unlimited budget for every /api route. Only admin
sessions skip now
- ?admin_preview=1 trusted a verified signature alone; it now applies the
same revocation, restore-cutoff, deactivation and password-change checks
adminAuth does, and reveal-mode reads the verified flag instead of
re-decoding the token
- the 50mb JSON limit is scoped to /api/admin and /api/v1; everything else
gets 2mb, so an unauthenticated body can no longer stall JSON.parse
- the CSRF Content-Type gate accepted multipart from any origin; cross-site
form posts are now rejected via Sec-Fetch-Site / Origin, with a Host match
fallback for same-origin installs that leave FRONTEND_URL unset
chunked-upload/init stored the client-declared mimeType on the photo row and
the gallery, secure-image and protected-image routes echoed it as
Content-Type, so a JPEG/HTML polyglot declared as text/html rendered inline
on the app origin for every guest. The admin photo route already resolved
the type safely (#908 review); that logic now lives in
utils/photoContentType and every serving route uses it.
The chunked path derives the MIME from the filename extension and requires
that extension to be on the admin allow-list, matching what the multipart
path enforces through its multer fileFilter.
Settings > Branding persisted logo_url / favicon_url verbatim and on clear
unlinked path.join(storage, url) behind a startsWith('/uploads/logos/')
check, which '..' segments pass. The business-profile PDF logo did the same
behind a /pdf-logo-\d+\./ marker test, and used absolute values as given.
Either let a settings.edit or settings.banking holder delete any file the
process can reach.
Both now resolve through helpers in utils/safePath that only ever name a
flat leaf inside the fixed directory. The /favicon.ico streamer is narrowed
the same way: it contained to the whole uploads/ root, which also holds
signed contracts and transfer files.
revokeToken() base64-decoded the payload without checking the signature and
inserted a row keyed on id-iat-type, the same key isTokenRevoked() matches
for real sessions. The logout endpoints are unauthenticated, so anyone could
forge a payload naming another user's id, type and login second and log them
out remotely; a far-future exp also left rows that cleanup never swept.
Expiry is still ignored so logging out an expired session stays idempotent.
Codex review round 2. The 400 I added in the previous commit returned
errors.array() verbatim, and express-validator puts the submitted `value` in
each error -- so rejecting an oversized password echoed that password back, and
re-allocated up to the 50mb body limit on an unauthenticated endpoint, partly
undoing the denial-of-service fix this branch exists for.
The same call appeared at seven sites in this file, five of which validate a
password field: /admin/login, /gallery/verify, /gallery/:slug/client-login,
/admin/change-password and /password-strength. Every failed login was returning
the attempted password in its response body, where it reaches proxy logs, error
monitoring and browser tooling. Fixed at all seven rather than only the one the
review pointed at.
Only `value` is dropped. `msg`, `path` and the rest are kept, because the two
shapes express-validator produces are both consumed in the frontend -- AcceptInvite
reads {field, message} from routeHelpers.validateRequest, EventDetails reads
{msg, path} from raw errors.array() -- and switching auth.js to the helper's
shape would have broken the latter for a reason unrelated to security.
1 more test. Backend suite: 2744 passed.
Codex review round 1 on the batch-1 security fixes. One finding is a
regression this branch introduced.
generateSecurePassword retried by recursing on any candidate validatePassword
rejected. The new 128-character cap makes EVERY candidate invalid once a caller
asks for more than that, so `generateSecurePassword({ length: 129 })` went from
returning a password to unbounded recursion and a stack overflow. It now
refuses an impossible length up front, and the retry is a bounded loop rather
than recursion -- every candidate failing is possible for reasons other than
bad luck (a charset that cannot satisfy the configured policy), and that case
deserves an error someone can act on rather than a blown stack. No caller in
the repo passes a length at all; the hazard was in the exported surface.
The route validators were decorative. POST /api/auth/password-strength never
called validationResult(), so the length bound I added only recorded an error
that nothing read: the oversized body still reached zxcvbn and the endpoint
still answered 200. The cap inside validatePassword() was doing all the work.
Errors are now returned as a 400 before the validator runs, which is what the
previous commit claimed.
Also awaited validatePasswordInContext, which is async. Unawaited, `validation`
was a Promise and every field in the response -- valid, score, errors, feedback
-- came back undefined. Pre-existing, in the lines this change already touches,
and it made the endpoint useless for the real-time validation it exists for.
1 more test. Backend suite: 2742 passed. The 23 eslint errors in server.js are
pre-existing and identical on main.
Two findings from the GHSA-pwx6-5pqc-c5xq scan bundle, both verified against
the code and reproduced before fixing.
**Unauthenticated denial of service via password strength (csf_495d53fa).**
POST /api/auth/password-strength takes `body('password').notEmpty()` with no
upper bound, sits behind express.json({ limit: '50mb' }), and hands the string
to zxcvbn, whose matching is superlinear and runs synchronously on the event
loop. Measured on this codebase, in ms of blocked loop: 128 -> 41, 512 -> 1367,
1000 -> 5097, 5000 -> did not return in two minutes. One unauthenticated
request of about a kilobyte stops the whole process for five seconds; a few
kilobytes stops it indefinitely. The /api/auth rate limit does not help when a
single request is already enough.
The cap lives in validatePassword() so it covers every caller, present and
future; the route validator is defence in depth. 128 keeps the worst case at
the cost of an ordinary request while staying far above any real password --
bcrypt consumes only the first 72 bytes, so length past that adds no entropy to
the stored hash anyway. This is the only unauthenticated reach into zxcvbn:
setup is token-gated and self-closing, and acceptInvite/adminAuth use the
regex-only validator in passwordGenerator.
**The /photos and /thumbnails static mounts (csf_9aa6afe6, csf_559cd5cc,
csf_b14d462e, csf_547d26fa, and the gallery half of csf_34e420af).**
They served the raw originals and thumbnail trees behind photoAuth, which
authorises on a slug match. A static file server cannot apply per-photo rules,
so everything the gallery API decides was absent: allow_downloads, per-category
allow_downloads, watermarking, the resolution cap, reveal-mode windows,
visibility='hidden', download logging, and the customer-assignment re-check
that makes revocation immediate. photoAuth also bcrypt-compares an
x-gallery-password header per request with no limiter -- both rate-limit gates
return early for non-/api paths -- so the mount was an unmetered password
oracle. The filenames needed to drive all of this are handed to every guest in
the photos listing.
Nothing builds these URLs: no reference in frontend/src, none in the email
templates, and the only backend mentions are the /api/admin/photos/... API
routes and a maintenance-mode prefix list. The equivalent authorised routes are
/api/gallery/:slug/photo/:id and /thumbnail/:id. nginx still proxies the two
locations; they now 404, which is the intent.
**The /uploads mount (csf_1fc92f57).** It exposed the whole uploads/ root with
no auth middleware at all, and that root also holds signed contract PDFs
(uploads/contracts/signed) and client transfer files (uploads/transfers/<id>),
reachable by anyone who learned or guessed a filename. Narrowed to the two
public asset trees it exists for; contracts and transfers keep their own
authorised routes.
Removing the mounts leaves src/middleware/photoAuth.js unreferenced by
application code. Left in place deliberately -- deleting it and its tests is a
separate cleanup, and a smaller diff backports more safely.
Backend suite: 2742 passed.
Closes#1275. Follow-up to #1263.
`matchMedia('(hover: none) and (pointer: coarse)')` answers "what is this
device's primary pointer", which on anything with both inputs is the wrong
question. A touchscreen laptop reports fine+hover, so a finger tap was handled
as a click: the photo opened with no reveal step and the tile's own actions
needed a hover a finger cannot produce. An iPad with a trackpad reports the
opposite, so a mouse click was handled as a tap and opening a photo took two of
them while hovering did nothing.
Pointer events carry the answer per interaction. useInputMode holds one
module-level mode fed by a single window-level listener pair, so every tile
agrees and the listener count does not scale with the grid. The primary-pointer
query stays as the opening guess -- it is right for the two single-input cases
that are most of the traffic, a phone and a desktop -- and the first real
interaction corrects it on a hybrid.
pointermove matters as much as pointerdown: a mouse announces itself by
approaching, and the mode has to be right BEFORE the click, not as a
consequence of it. A pen is grouped with touch, since it taps rather than
hovers on most hardware and being wrong that way costs only a reveal step.
GalleryPremiumLayout's touch rules move off the media query onto a
data-input-mode attribute the layout sets, for the same reason: on a
touchscreen laptop the query stayed false and a finger could never reach the
checkbox or like button, and on an iPad with a trackpad it stayed true and both
were stuck on permanently.
The #1263 guarantee is unchanged and pinned by a test that walks all three
modes: a control that cannot be seen cannot be hit, whichever input is in use.
Verified in the running app under Chrome touch emulation, on a device
advertising a coarse primary pointer -- the iPad-with-trackpad case. A mouse
merely moving switched the grid to hover semantics and revealed the overlay,
and a subsequent tap switched it back; the premium layout's attribute followed,
with its checkbox reachable under touch and hidden-but-inert under mouse. The
mirror case (finger on a fine-primary device) cannot be staged in Chrome, which
couples touch emulation to a coarse primary pointer, so it rests on the jsdom
tests.
12 tests: 8 on the store, 4 more on PhotoCard. 3 of the 4 fail without the
per-interaction mode; the fourth is the #1263 no-regression guard and holds on
both sides by design.
Codex review round 4 on #1273.
Settings → Status rendered a green check for the email processor
unconditionally, against an API field that was itself the literal 'active'.
Both ends were lying and only one of them got fixed: adminSystem started
reporting the real state in an earlier commit, but StatusTab never read it, so
the second place an admin looks to find out why mail is not arriving still said
everything was fine. It now shows stopped and degraded, with the reason.
The truncation flag missed the case it most needed to cover. The loop broke on
the 200-row report cap before the flag could be set, so 201+ overdue rows came
back as exactly 200 with scanTruncated false -- a partial report presented as
complete. It is now set whenever rows were left unexamined.
The grace-window comment claimed the processor clears ~6000 rows inside the
window. It clears on the order of 100: ten rows a pass, one pass a minute. The
comment now says so, and says why the processor's own state is reported above
the list rather than inferred from it -- "running, last pass sent 10" next to a
backlog reads very differently from "not running" next to the same backlog.
One round-4 finding is NOT fixed, deliberately, and is written up at the retry
route. Clearing scheduled_at leaves created_at at the original enqueue time, so
a retried old row appears in the waiting list immediately, looking overdue,
until the processor sends it. Restarting that clock needs a timestamp written
there and no shape works: a Date matches how queueEmail writes the column and
how processEmailQueue compares it, but jest's sandbox Dates store as
"[object Object]" (CLAUDE.md) so it cannot be tested; an ISO string tests fine
but stores as TEXT, which SQLite then orders above the numeric bound in the
processor's own pickup query, leaving the row unsendable. A requeued_at column
would settle it. Cosmetic either way, and not worth risking a stuck row.
1 more test, failing before this commit.
Codex review round 3 on #1273. The first finding reverses a round-1 fix of
mine, correctly.
Retry no longer sends. Round 1 flagged that retry was a no-op for waiting rows
and offered two remedies: give them a send-now action, or stop showing them
Retry. I took the first, and round 3 showed why it is the wrong half --
processEmailQueue claims nothing before invoking the transport, so a flush
overlapping the scheduled pass has both of them sending the same email. Saving
60 seconds is not worth a duplicate landing in a customer's inbox, and a claim
protocol would need a status no query watches plus a reaper for rows abandoned
mid-send. So retry is a reset again, as it was on main.
Waiting rows now carry no actions at all, which is the other half of that
round-1 remedy and closes a worse hole the shared table opened: Dismiss DELETEs
the queue row. Those emails have not failed and still go out once the processor
recovers, so clicking the tidy-up icon on a health warning silently cancelled a
customer's mail. The section is diagnostic; what a waiting row needs is the
processor fixed, which the panel above it now says.
The grace window runs from when a row became DUE, not from when it was queued.
A split-payment invoice created three days ago and scheduled until a minute ago
has had one minute of the processor's attention, and measuring from created_at
reported every scheduled mail as unworked the instant it came due -- which is
most of what this panel would then have been showing.
A truncated scan can no longer read as an all-clear. The scan is bounded, so a
queue larger than the budget whose head is all future-scheduled can hide a due
row past the last page read; the response now says so and the UI withholds the
green check.
The test fixtures were wrong in a way worth keeping: scheduled_at also defaults
to CURRENT_TIMESTAMP, so back-dating created_at alone built rows that cannot
exist in production -- old, but scheduled for the moment the fixture ran. The
helper now back-dates both, as the database would have.
3 more tests; the two that pin new behaviour fail before this commit, and the
reverted flush is pinned by asserting the transport is NOT invoked.
Codex review round 2 on #1273. Both findings restore the false all-clear that
round 1 set out to remove, by different routes.
Both timestamp columns default to CURRENT_TIMESTAMP, which SQLite renders as a
zone-less 'YYYY-MM-DD HH:MM:SS' in UTC -- and Date.parse reads that shape as
LOCAL time. On a TZ=America/New_York deployment a row due now looked four hours
away and never reached the waiting list; nine hours the other way, fresh mail
read as long overdue. The parser now stamps the zone the value actually
carries.
That parser moved to utils/queueTimestamps so it can be tested honestly. This
suite runs in UTC, where reading a zone-less value as local and as UTC give the
same answer, and process.env.TZ does not reliably re-bind mid-process -- my
first attempt at these tests passed against the broken code for exactly that
reason. They now force TZ in a child process, so they fail on any host.
The candidate rows are paged rather than cut off with one LIMIT. The time
filter runs in JS, so a queue holding more than a page of future-scheduled rows
-- split-payment invoices are exactly that shape -- filled the window with rows
that all got filtered out and hid the due row behind them, reporting nothing
waiting. Paging also drops the dependency on ORDER BY created_at meaning
anything, which it does not on SQLite once numeric and text timestamps mix.
Bounded at 10k scanned; past that the response is a sample, which the 200-row
cap already made it.
12 more tests. The paging one fails before this commit, and all four
naive-timestamp ones fail against the old parsing on any host.
Codex review round 2 on #1274. Both findings are the same shape as round 1:
a message that asserts more than the response supports, and sends the admin
somewhere that makes it worse.
A 5xx is no longer treated as a clean failure. createInvitation inserts the
customer_invitations row and only then queues the email, with no transaction
around the pair, so a 500 out of the queueing step leaves an OPEN invitation
behind. Telling the admin "no invitation went out, retry" there walks them into
a 409 that still queues nothing. 5xx now joins the no-response case as
unconfirmed; a plain 4xx keeps the clean-failure message, because that is the
one shape where nothing was written.
The two already-active conflicts now say so. The send-invite route returns
CUSTOMER_ALREADY_ACTIVE from its own check, but createInvitation rechecks
customer_accounts afterwards and threw a bare ConflictError -- code CONFLICT,
indistinguishable from the pending-invitation conflict. An invitation accepted
between the two checks therefore landed in the pending branch, telling the
admin to cancel an invitation that acceptance had just closed. Both conflicts
in the service now carry a code of their own, so the client reads the code
rather than inferring from the status.
4 more tests. One round-1 test changed with the behaviour it pinned: its 500
now asserts the unconfirmed message, and a new 400 case covers the clean
failure it used to stand for.
Codex review round 1 on #1273. One of the four is a real bug on every SQLite
deployment.
The waiting-row query compared `created_at` against a bound ISO string. On
SQLite that column does not hold a string: queueEmail writes a JS Date and the
native binding stores epoch ms, and SQLite orders INTEGER before TEXT
regardless of value -- so the comparison was true for EVERY row. Mail queued a
second ago read as ten minutes overdue, and a scheduled_at years in the future
read as already due. Confirmed directly against sqlite3: a 2026 row matches
`created_at <= '2020-01-01T00:00:00.000Z'`.
Binding a Date instead is not the fix, since knex hands sqlite3 a Date the same
way and jest's sandbox Dates stringify to "[object Object]" (CLAUDE.md). So the
engine-safe half of the predicate stays in SQL and the two time comparisons
move into JS behind a toMillis() that accepts all three shapes this column
really has -- Date from Postgres, ms-number from SQLite, ISO string from
fixtures and older rows. The scan is capped at 1000 pending rows ordered
oldest-first; everything overdue sorts into that window, and the response was
already capped at 200. The existing tests missed this because they store ISO
strings, which is what CLAUDE.md prescribes for jest -- so the new ones store
epoch ms, the production shape, and one mixes both in a single queue.
Retry was a no-op for the rows it most needed to help. It wrote pending /
retry_count 0 / no schedule, which is exactly what a waiting row already is:
the row came back unchanged while the toast said it had been re-queued. And
since the usual reason a row is waiting is that nothing is working the queue,
deferring it to the next pass is the one answer that cannot help. It now
follows the reset with the same single-row flush the project cockpit uses.
An idle pass no longer inherits the previous pass's totals -- the no-pending
early return skipped the lastResult assignment, so System Health kept
attributing an old sent/failed count to a run that did nothing.
"All clear" now means the whole queue is clear, which is what the PR claimed
and the code did not do. An empty waiting list is only reassuring when
something is working the queue: a processor stopped a minute ago has no overdue
rows yet either, and a green check there is the same false all-clear this
branch exists to remove.
7 more tests. The 5 that pin new behaviour fail before this commit; the SQLite
ones fail in the way the bug predicts rather than erroring.
Both new tests stub the webhook transport with a spy rather than pointing it at
a dead port: real connection attempts left open handles that destabilised
unrelated suites in the same jest worker.
Codex review round 1 on #1274. Three places where this branch replaced one
overclaim with another.
The badge tooltip said "Invitation sent". createInvitation inserts the
customer_invitations row and only then queues the email, with no transaction
around the pair, so an open invitation does not prove an email_queue row exists
-- and even when it does, delivery is the queue processor's business minutes
later. The tooltip now describes the invitation link itself and points at
System health, which is the same distinction #1273 draws.
Both conflicts are 409 and were being treated as one. Migration-era
send-invite returns 409 with code CUSTOMER_ALREADY_ACTIVE when the customer
already has a password, which happens if an open invitation for that address is
accepted between createDirect and sendInvite. There is then no invitation row
to cancel, so directing the admin to the Invitations tab points them at
something that does not exist. The code is now read before the message is
chosen.
A dropped connection or a timeout rejects with no `response` at all, and the
request may well have succeeded server-side. Saying "no invitation went out"
there sends the admin into a retry that then 409s, which is the same trap the
CUSTOMER_ALREADY_ACTIVE case sets. That branch is now explicitly unconfirmed
and says where the answer is.
3 more tests, all 3 failing before this commit.
Codex review round 1 on #1272. Both findings are consequences of extending the
Grid/Justified tap-to-reveal model to every layout: what those two layouts got
away with, because they were the only ones using it, becomes wrong once
Masonry, Mosaic and Timeline inherit it.
The hover variants no longer hide behind `md:`. On a fine pointer under 768px
`isTouchDevice` is false, so nothing reveals the overlay, and the `md:` prefix
disabled the only hover variants there were -- the controls stayed
`opacity-0 pointer-events-none` with no way to reach them. Grid and Justified
already behaved that way, but Masonry, Mosaic and Timeline had unprefixed
`group-hover:` and revealed at any width, so this was a regression for them.
Width was never the real question: what the breakpoint was standing in for is
that :hover latches on a touchscreen once a tile is tapped. So the variants are
emitted for pointer devices only and withheld on touch, which says that
directly.
detectCoarsePointer no longer ORs the touch fallbacks over matchMedia.
matchMedia describes the PRIMARY pointer; `ontouchstart` and `maxTouchPoints`
only say a touchscreen exists somewhere, which is equally true of a touchscreen
laptop or a docked tablet being driven by its mouse. OR-ing them classified
those as touch-only, so an ordinary click merely revealed the overlay and
opening a photo took two clicks. The fallbacks now stand in only where
matchMedia is absent, which is what the comment already claimed.
3 more tests, all 3 failing before this commit.
Closes#1261.
"Invite customer" is two calls: createDirect, then sendInvite. The mode wiring
is right -- CustomerManagementPage passes mode='invite' and
InlineCustomerCreate does call sendInvite -- so the reported symptom is not a
missed branch. It is that nothing downstream distinguishes the outcomes.
Three things could not be told apart afterwards:
- The success toast claimed "portal invitation sent". sendInvite only queues an
email_queue row; whether it was delivered is decided minutes later by the
queue processor. The toast now says queued, and says what sends it.
- When sendInvite failed, the warning read "Invitation email failed -- retry
from the customer detail page", which sounds like the mail bounced. What
actually remains is a PASSIVE customer with no invitation at all, so it says
that instead. A 409 is now separated out: that means an invitation for the
address is already open and the RE-invite was refused, so the customer is
invited and telling them to retry sends them the wrong way.
- The customers table rendered a customer whose invitation never went out
identically to one the admin created as passive on purpose -- both showed
only "Passive - admin only". Passive customers with an open invitation now
show "Invitation pending", matched case-insensitively because
customer_invitations lowercases the address while customer_accounts keeps
what the admin typed. The invitations list was already being fetched for the
tab; this only cross-references it.
Active customers are left alone: they have portal access, so a stale
invitation row for their address says nothing about them.
7 tests; the 5 that assert the new behaviour all fail before the change, and
the 2 negative controls pass on both sides.
Closes#1262.
"Gallery email queued" reads as a delivery confirmation, and System Health
agreed with it: "No stuck or failed emails -- all clear", while not one email
had gone out.
Both statements were true and neither was the one the admin needed. Queueing
writes an email_queue row at status='pending', retry_count 0 -- nothing more.
/failures matched only status='failed' or pending-with-retry_count>=3, so it
matched none of those rows, and there are two ordinary ways they never leave
that state:
- startEmailQueueProcessor() was never reached, so nothing polls the queue.
- Every pass returns early. processEmailQueue bails when the transporter will
not initialise, before it touches a single row, so retry_count stays 0 and
no error_message is ever written. A working SMTP test button does not
contradict this: that path builds its own transport.
adminSystem.js made it worse by reporting `emailProcessor: { status: 'active' }`
as a literal, so the one place that named the worker always said it was fine.
- emailProcessor records what each pass did -- started, lastRunAt, lastResult,
lastError -- and exports getQueueProcessorStatus(). The transporter bail and
the queue-query failure, the two silent early returns, both write lastError.
- /failures gains `waitingEmails`: pending, under the retry cap, past any
scheduled_at, and queued more than 10 minutes ago. The predicate mirrors the
processor's own pickup query, so a row listed there is one it should already
have taken; rows over the cap stay in `stuckEmails` and are not counted
twice. A future scheduled_at is left alone -- split-payment invoices and the
business-hours floor park rows deliberately.
- System Health leads with the processor's state (running / stopped /
degraded) and lists waiting emails in their own table. The all-clear now
needs both buckets empty.
- adminSystem reports the real processor state instead of the literal.
- The two "queued" toasts say the queue processor is what sends it and where
to look if it doesn't arrive.
8 route tests, all 8 failing before the change.
Closes#1263.
A tap on a photo tile did one of three things depending on where the finger
landed: opened the photo, downloaded it, or liked it. The cause is that
`opacity-0` hides pixels but not hit-testing. The overlay's View/Download/Like
buttons and the selection checkbox were rendered at opacity 0 and left fully
tappable; each one calls stopPropagation, so hitting an unseen button both
fired its action and suppressed the tile's own open.
On a pointer device hover reveals the controls before anyone can click them, so
the gap never showed. On a touchscreen there is no hover, so in Masonry, Mosaic
and Timeline the controls were invisible for good and tappable for good.
Visibility and hit-testing now move together. PhotoCard computes both from one
place, so every layout that uses it gets the same rule instead of passing its
own opacity classes:
- `touchAware` is gone. It gated the tap-to-reveal state machine, and only Grid
and Justified opted in -- which is why those two behaved and the other three
did not. Every PhotoCard layout is touch-aware now: first tap reveals the
controls, second tap on a control acts, second tap elsewhere opens the photo.
Pointer devices keep hover semantics unchanged.
- The pointer reading moved from an effect into the initial state. As an effect
it landed a mount-time render between the tile measurement in useLayoutEffect
and the image mount that measurement gates, remounting every card once --
caught by the #1095 regression test, which is the reason that test exists.
It also now degrades to ontouchstart/maxTouchPoints where matchMedia is
absent, since every layout runs this path now.
Two more instances of the same class, outside PhotoCard:
- GalleryPremiumLayout's checkbox and like button are CSS-hidden the same way.
They get pointer-events alongside opacity, and because that layout has no
reveal gesture, a `(hover: none)` block shows both outright at a finger-sized
target rather than leaving them unreachable.
- PhotoGrid's download button called `onClick={onDownload}` with no
stopPropagation, so downloading also opened the lightbox.
Verified on a mobile viewport with real touch emulation: at rest the tile
centre now hits the image rather than an unseen Download button, and one tap
reveals the controls instead of downloading the file.
5 tests, all 5 failing before the change.
The gallery password is one shared secret per event and does not
distinguish people. With the guest identity outliving the tab, logging out
and letting the next person enter that password greeted them by the
previous guest's name, with "forget me" - which erases that guest's
selections server-side - one click away. Logout is the leaving-this-device
signal, so it now drops the local identity too. Server row untouched.
A guest coming back through their own already-redeemed link is the
ordinary #1265 case, and the identity the device holds is theirs. The same
link opened on a shared device that holds another guest's identity is not:
the redemption 409s, ensureIdentity() falls through to the stored identity,
and the visitor's likes are filed under the previous person.
The two cases were indistinguishable client-side, so the 409/410 body now
carries the invite's guest_id. On a mismatch the stored identity is cleared
and the visitor is asked who they are. A response without guest_id keeps
the previous behaviour.
Two defects in the storage fallback, both reproduced:
The quota fallback repointed reads at sessionStorage through module state,
which a reload discards. The next page load probed localStorage, passed
the one-byte probe, tried to promote the pair and was refused on the same
quota, swallowed that, and read an empty localStorage: the identity sat one
store over, unreadable, and the guest re-registered. Reads are now
read-through: primary store first, sessionStorage second, promoting into
the primary only when it will take the pair and leaving it where it fits
when it will not. No module state has to remember which store won.
The migration wrote the token before the profile, so a store that accepted
the first write and refused the second left a token with no profile:
x-guest-token was sent while the provider prompted to register, producing
a second row with two live tokens. Every write is now profile-first and
rolls back on failure, so a store holds the whole pair or none of it.
Last open finding from codex round 3 on #1268.
Invite redemption is async and the gallery stays interactive while it runs, so
a like clicked in that window resolved against the persisted identity and was
filed under the wrong guest permanently. ensureIdentity() now waits on the
in-flight redemption and re-reads the result before falling back to the stored
identity or the prompt.
Codex review round 3 on #1268. Three of these were defects in the round 1-2
fixes themselves.
Consumers holding local feedback state are now rebuilt on an identity switch.
Invalidating queries was not enough: six gallery layouts seed their liked set
behind a mount-only likedSeededRef ('so refetches don't clobber in-session
optimistic toggles') and PhotoLightbox keeps its own copy, so a refetch left
the previous guest's hearts on screen. The provider re-keys its subtree, which
covers all seven without touching them. Deliberately only on a switch away
from an established identity -- remounting on first sign-in would tear down
the gallery under the click that triggered the prompt and drop the pending
action.
The storage fallback now repoints reads. storeGuestIdentity wrote to
sessionStorage when localStorage rejected the real write but left
resolvedStorage on localStorage, so every later read missed: x-guest-token was
never sent and the identity vanished on reload. The fallback looked like it
worked while achieving nothing.
Clearing an identity now notifies this tab. Native storage events fire only in
other documents, so the interceptor dropping a server-rejected identity left
the provider still showing that guest and ensureIdentity() still handing it
out. A same-tab event completes the loop.
Cross-tab adoption resolves pending callers. A tab parked on the prompt
awaiting ensureIdentity() while another tab registers now completes exactly as
register() does, instead of hanging forever and registering a second guest if
the visitor submits the still-open prompt.
Codex review round 2 on #1268. Four findings, all reachable only because the
identity now persists.
An explicit ?invite= now takes precedence. The redeem effect skipped when an
identity already existed, which was harmless while identity died with the tab.
Persisted, it means opening guest B's invite on a browser where guest A once
visited restores A, never redeems B's invite, and files B's likes under A. A
ref keeps it to one redemption per token.
Guest-scoped caches are invalidated when the identity changes. my-feedback,
gallery-photos and photo-feedback are keyed by slug and photo id, never by
guest, so they outlived an identity change and showed the previous guest's
likes while requests already carried the new token. Now reachable three ways:
another tab, 'Not you?', and an invite redeemed over an existing identity.
An identity the server has rejected is dropped. resolveGuest nulls req.guest
for a soft-deleted or merged-away row even when the JWT is validly signed and
unexpired, and the route answers GUEST_IDENTITY_REQUIRED — no client-side
expiry check can catch that. Self-limiting when identity died with the tab;
persisted, it would fail every like for up to 30 days while the footer still
showed the guest's name.
The write fallback now covers the real write, not just the probe. A one-byte
probe fits in a nearly-full store that still rejects a JWT plus profile, which
left the context believing it was signed in with nothing persisted.
Codex review round 1 on #1268. All three findings are consequences of the
storage move itself.
Expired tokens now read as absent. GUEST_TOKEN_TTL is 30 days and
sessionStorage almost never survived that long, so 'stored but expired' was
unreachable before; persisting the token makes it routine. Nothing else
clears it -- the 401 handler in config/api.ts only drops gallery_event_<slug>
-- so the visitor was shown as signed in while every like 401'd, and
ensureIdentity() short-circuited so recovery was never offered. The signature
is still the server's business; an unparseable token is left alone.
Tabs now stay in step. localStorage is shared where sessionStorage gave each
tab its own copy, so 'Not you?' or a registration in one tab silently changed
the token every other tab sends while they still displayed the old name --
their likes would land on the new guest, the exact misattribution this branch
set out to stop. A storage listener rehydrates the others.
Storage is probed for writability, not just readability. A store that reads
but throws on setItem (quota, private mode) sailed past the read-only guard,
and storeGuestIdentity threw after the server had created the guest: failed
registration, retry, duplicate row. Writes are also wrapped so a storage
failure degrades to a per-session identity instead of rejecting registration.
Closes#1265.
The guest JWT and profile lived in sessionStorage, so the practical lifetime
of an identity was "until this tab closes". GUEST_TOKEN_TTL was raised to 30
days in #1216 specifically to stop identity churn, but it governs how long the
token stays valid, not how long the browser keeps it -- so it was almost never
reached.
A guest who closed the tab and came back through the same emailed link got the
registration prompt again, and the ?invite= token in that link is single-use
and already redeemed, so it could not put them back. Typing the same name
inserted a second gallery_guests row: their earlier likes then belonged to an
identity they could no longer act as, and could not be removed.
Moved to localStorage, which is the reporter's suggestion and the one that
lines up with the TTL that already exists.
This does not reopen the objection #1216 raised. Deduplicating on a typed
email was rejected there because anyone knowing an address could claim that
person's identity, and answering differently for a known address leaks which
addresses are in the gallery. This grants nothing to anyone -- it only stops
the browser discarding a token it was already given. Gallery ACCESS stays in
sessionStorage (galleryAuthStorage.ts) and is untouched, so a returning
visitor still has to pass the gallery password before a stored identity means
anything.
Two things the storage swap alone would have got wrong:
- Anyone with a gallery open at upgrade time would be treated as a new guest
on their next reload -- the exact duplicate-row bug this fixes, fired once
per in-flight guest. getGuestToken/getGuestIdentity now move a pre-#1265
sessionStorage entry across on first read. It moves rather than copies, and
a fresh registration in the current tab always wins over a stale copy.
clearGuestIdentity clears both stores, so "forget me" cannot be undone by a
leftover being migrated back.
- Identity now surviving a tab close means a second person on a shared device
can be greeted by the previous visitor's name. Their only exit was "Forget
me", which soft-deletes the guest row and anonymizes their feedback -- it
would erase the wrong person's selections. Added a non-destructive
signOut() and a "Not you?" control next to it, which only clears the
identity on this device.
Storage access already funnelled through one getStorage() accessor, so the
swap is a one-line change there; it falls back to sessionStorage when
localStorage throws (Safari private mode, blocked by policy) rather than
dropping identity entirely.
6 tests. 3 fail against the old implementation, including the core "survives a
tab close" case; the other 3 pin the migration and the both-stores clear.
Note: the new "Not you?" string is added to en and de. i18n:ci is already red
on main (11,405 missing keys) because the extractor there manages six locales
while only en/de are kept at parity; this adds 4 entries of that same class.
PR #1267 fixes the check itself.
The general /api limiter had been inert since it was written, so its 100
requests per 15 minutes per IP was never exercised against real traffic.
Applying it for the first time with that budget would have 429'd a venue
wifi NAT after roughly twenty guests per window, since every call a
gallery landing page makes before the password is typed counts. 300 keeps
the protection and clears the realistic case. An explicit app_settings
value still wins over this fallback.
The SSRF vetting is resolve-then-fetch and production-only. Say so, and
say why that is acceptable: the hostname is admin-controlled, the request
is confined to allowlisted paths and carries no PicPeak credentials, and
the S3/MinIO client already takes the same posture.
The local escapeLike copy and its comment predate 0ef51148, which stopped
escapeLikePattern() doubling single quotes. The comment was therefore
false and the helper byte-identical to the shared one. Use
escapeLikePattern() + likeWithEscape(), as every other search does.
POST /api/auth/admin/change-password and POST /api/customer/profile/password
both verify the current password before replacing it, which makes them a
credential check an attacker holding a hijacked session can drive at will:
the session's own JWT skips the general limiter as authenticated, and they
were not in the auth gate's table. Both join it. Only failures count, so
the one change a user legitimately makes costs nothing.
repairGerman gated subject, body_html and body_text on body_html alone, the
same defect Codex found in migration 194: an admin who had translated only
the subject lost it the moment the HTML still matched English, and down()
is a deliberate no-op, so the loss was unrecoverable. Each field is now
judged independently for both the translations row and the legacy _de
columns, matching 194's corrected pattern. Two tests pin the two
directions (translated subject over English body, and the reverse).
Express's `case sensitive routing` is off by default, so /API/admin/events
reaches the same handler as /api/admin/events. Both the gate's `/api/` prefix
test and rateLimitService's public-endpoint classification compared the raw
path, so simply upper-casing a letter skipped the limiter entirely.
Verified against a real Express app before fixing: /api/admin/events routes
and hits the gate; /API/admin/events and /Api/Admin/Events route and miss it.
Both now match on a lower-cased path. The auth gate added alongside was
already immune -- its patterns carry the `i` flag for exactly this reason.
Not changed: rateLimitSecurity.hasValidAdminToken's /api/admin/ test has the
same shape, but there the case-sensitive comparison fails safe -- an
upper-cased path simply does not get the admin skip, so it is rate limited
rather than exempted. Making it case-insensitive would widen a skip, so it is
left alone. maintenance.js's isAdminRoute is fail-safe for the same reason.
The five authRateLimiter registrations were inert for the same reason the
general one was -- registered below the error handler. Auth endpoints have
never had an IP limit; the 5-attempt behaviour QA observed is the per-account
lockout in authSecurity.js, which is a different mechanism and is untouched.
They could not simply be activated: app.use('/api/auth', ...) is a prefix, so
a 5-per-window budget would have covered GET /api/auth/session and
POST /api/auth/password-strength, which the frontend calls far more than five
times per window. That locks users out.
The real surface was enumerated by loading the routers and walking
router.stack rather than grepping, which showed two of the five registrations
pointed at routes that do not exist: adminAuth.js has no /login (admin login
is POST /api/auth/admin/login) and there is no /api/gallery/:slug/verify
(gallery verify is POST /api/auth/gallery/verify).
Now limited, on exact method+path: admin login, admin MFA verify, gallery
password verify, share-login, client PIN, setup verify-token, setup admin,
customer login, customer password-reset. Deliberately unlimited: session
checks, password-strength, logouts, authenticated change-password, the SSO
round-trip (a 429 on the callback breaks login from shared corporate IPs),
and one-time invite/accept-invite links.
Two choices carry the design. skipSuccessfulRequests means only failed
attempts spend budget, which is what makes 5-per-IP survivable behind NAT --
ten guests on one venue wifi all typing the correct gallery password consume
nothing -- and means a legitimate admin cannot be locked out by their own
success. And the limiter keeps its own rateLimit() instance, hence its own
store and its own per-IP bucket, with the general gate's auth exemption left
in place: sharing a counter is exactly the lockout described above.
Patterns are case-insensitive because Express's case-sensitive routing is off
by default, so POST /api/auth/admin/LOGIN reaches the login handler and a
case-sensitive pattern would have been a free bypass.
max is now read per request, so the Settings UI's rate_limit_auth_max_requests
applies without a restart, matching the general limiter.
Tests prove both directions: each credential endpoint 429s on attempt 6 with
the response shape the four login pages already branch on, each benign
endpoint still returns 200 after 40 calls, the two buckets are independent in
both directions, and 30 consecutive successful logins consume no budget.
Refs testplan REPORT.md, rate-limiter gap.
Adds varsIgnorePattern and ignoreRestSiblings to no-unused-vars, the config
recommendation left open when the lint backlog was cleared.
The "omit fields via rest spread" idiom is intentional and recurring --
adminEvents/helpers.js destructures password_hash and client_password_hash
purely to keep them out of `...rest` -- and without ignoreRestSiblings every
occurrence needs its own disable comment, which is noise that also suppresses
genuine findings on the same line. Removed the one such comment that now
exists; the explanatory comment above it stays, since the intent is not
obvious from the code.
Lint stays at 0 problems.
Refs testplan REPORT.md D1.
Cleaning up after our own changes, not pre-existing dead keys.
- gallery.feedback.* (10 keys) -- StoryFeedbackSheet was their only consumer
and it was removed in 3ef4bd8c as an unreachable duplicate of the lightbox.
- cssTemplates.title and settings.moderation.wordFilters -- orphaned by
b80ce73e, which removed the component-side heading on the tabs that rendered
a heading identical to the shell's.
- settings.analytics.customCspWarningText -- superseded by
customOnlyCspWarningText in 9251745a, which was deliberately a new key so the
stale pre-proxy string could not win over the new inline default. The sibling
customCspWarning title is still in use and stays.
Each verified to have zero t() references in src before removal. Key-diff
against HEAD: en/de -13, the six partial locales -12 (they never had
customCspWarningText), 0 changed and 0 added in any of the eight.
removeUnusedKeys is false by design, so this had to be a deliberate pass.
The report asked whether this was intentional. It is collateral damage from
the #386 swap, not intent.
There are two header download affordances. GalleryView sets
showDownloadAll={false} unconditionally -- "replaced by the new
showHeaderDownload (#386)" -- and passes showHeaderDownload={allowDownloads}.
GalleryLayout renders HeaderDownloadButton in the standard, minimal and hero
branches, but the isNoHeader branch only ever had the now-dead showDownloadAll
button. Net effect: zero download CTA on headerStyle 'none'.
The comment claiming intent -- "Intentionally NOT shown in the no-header
variant where the gallery is fully chromeless by design" -- is factually wrong
about its own branch: isNoHeader renders the menu button, headerExtra (upload
button, countdown timer) and logout. It is a functional-controls bar, not
chromeless. The sentence predates the #386 swap, when showDownloadAll still
gave that bar a download button.
Renders HeaderDownloadButton in that branch in the same slot order as the
other three; it is icon-only below sm, so it fits the compact bar. Removed the
two now-false comments.
Beta themes are unaffected: gallery-premium and gallery-story return from an
earlier branch that never mounts GalleryLayout and get download-all via their
own onDownloadEverything prop, so there is no double CTA.
Refs testplan REPORT.md, headerStyle:none download-CTA warning.
Three warnings on the event-details surface.
?tab= deep links were ignored -- activeTab was hardcoded to 'overview' and
nothing read or wrote the search param, unlike Settings. Mirrors SettingsPage's
pattern exactly (module-level key list + type guard, seed useState from the
param, write-back and reflect-back effects), plus a snap-back for the `guests`
tab, which only renders when identity_mode is 'guest' -- a deep link to it on
any other event would otherwise show a tab bar with no content. The snap-back
is guarded on the query's isLoading so it cannot fire against undefined
settings and kill a legitimate deep link.
Worth recording: the two effects ping-pong infinitely if activeTab and a valid
URL tab disagree at mount, which is exactly the pre-fix state. The seeding is
what makes them agree, so the fix is also what makes the pair safe.
Offline Photos tab rendered the "no media uploaded yet" empty state on a
failed fetch, because `data: photos = []` makes a rejected query
indistinguishable from an empty one -- a user could reasonably think their
photos were gone. Threaded isError through and added a third branch, reusing
TaxReportPage's existing error-with-retry shape. Needed no new keys.
The spurious "Upload completed successfully" toast was in the host, not the
uploader: PhotosTab hung toast.success off PhotoUpload's onUploadComplete,
which is documented as a grid-refresh signal and fires as soon as the transfer
loop exits -- including when the request 400'd on the photo cap or every file
was rejected by magic-byte validation. PhotoUpload's own toasts were already
correct. Removed it, and added a real partial-success branch reporting the
actual split instead of a plain "Upload complete!".
The guest uploader had a variant of the same bug in a different place: its
toast is gated on successCount, but successCount++ fired on any resolved
request -- and the upload route answers 202 with count: 0 and an errors[]
entry when the file is refused. So a refused guest photo produced "Upload
completed successfully (1 photos)" and pushed a useless upload_id into the
processing poll. Now gated on count.
Refs testplan REPORT.md, ?tab= / offline-empty-state / spurious-toast warnings.
Two warnings, both of which turned out to be mis-stated.
Search: the name printed on every card is photos.original_filename (not
source_filename, which is the replacement-stable ingest key and is not in the
gallery payload at all), but search matched only the stored renamed filename.
So a substring the admin or guest can literally read on screen returned zero
results. Fixed on the admin Photos tab, which filters server-side -- grouped
OR, because the feedback AND/OR conditions are appended immediately below and
a bare orWhere would leak across them -- and on the Story theme's own scene
filter, which is a second independent client-side search box.
Dates: the warning read "Transfers uses DD/MM/YYYY while the rest of the app
uses long-form dot dates", but it is inverted. TransfersPage already routes
every date through useLocalizedDate and was correctly honouring the rig's own
configured general_date_format of {"format":"DD/MM/YYYY","locale":"en-GB"}.
The surfaces it was compared against are the ones ignoring the admin setting,
by passing an explicit format string that overrides it. Dropped the hardcoded
'MMM d, yyyy' from the two EventsListPage table dates so they follow the
setting like Transfers does.
AdminHeader's format(new Date(), 'PPPP') is left as-is: that is the decorative
"today" banner, where a long weekday form is a deliberate design choice rather
than a data date, and forcing it to DD/MM/YYYY would read worse.
Refs testplan REPORT.md, search-by-original-filename and transfers-date
warnings.
app.use('/api/', generalRateLimiter) lives inside initializeRateLimiters(),
which is defined at line 463 but not called until 1048 -- by which point the
routers (767+), the /api notFoundHandler (1002) and errorHandler (1029) are
already on the stack. All six app.use() calls in it therefore append BELOW the
error handler and can never see a request. generalRateLimiter had no other
registration path.
So the entire /api surface had no IP-based request limit, except the handful
of routes carrying their own inline rateLimit() (public quotes, contracts,
payment-check, transfers, the analytics proxy). The admin Settings
rate-limiting UI -- rate_limit_enabled, rate_limit_max_requests -- was writing
to a control that did nothing.
Fixed with a stable gate registered above the routers that resolves the
limiter per request, so there is no boot delay: it is a pass-through until
initializeRateLimiters() resolves, exactly matching prior behaviour.
Registered unmounted (app.use(gate), not app.use('/api', gate)) because
Express strips the mount path from req.url and rateLimitService's own logic is
written against the full path -- req.path.startsWith('/api/public/') and the
/api/(gallery|secure-images)/:slug regex it uses to find a gallery token to
skip on. Mounting it would have silently broken both.
Deliberately excluded, each for a concrete reason:
- /health and /api/health, mounted above the gate: a 2s probe is 450
req/window and would 429 the container healthcheck.
- /api/public/transfer and transfer-upload: one request per file from a link
holder with no JWT, so never skipped as authenticated; a large transfer
would be cut off mid-way. Both already have tighter per-minute limiters.
- login and gallery-verify: the limiter returns authMaxRequests (5) as their
budget but counts them into the SAME per-IP bucket as every other /api call,
so the branding and settings fetches a login page makes before anyone types
a password would 429 the login itself for a full window. Giving these a real
per-IP limit means giving them their own bucket.
Bulk gallery and admin traffic is unaffected: skip_authenticated defaults true
and cookie tokens are promoted to Authorization before the gate runs, and
skipped requests do not increment the counter.
Also adds /api/health as an alias of /health -- one handler, identical
exposure -- which silences a ~2s probe warning. Registered above the API
middleware chain deliberately: left at its original position it would have
passed through apiRequestLogger and through maintenanceMiddleware, whose
skip-list contains /health but not /api/health, so it would have 503'd during
maintenance while /health returned 200.
The tests pin registration depth by source inspection as well as behaviour,
because depth is what was broken and no unit test of the gate can catch it.
Refs testplan REPORT.md, /api/health warning; rate-limiter gap found while
fixing it.
Verified against a real SQLite connection -- each of these returned zero rows
before and the right row after:
"Sarah's" before=[] after=["Sarah's Birthday"]
"100%" before=[] after=["Summer 100% Sale"]
"Gala_" before=[] after=["Gala_Night"]
Two bugs in one helper. It did .replace(/'/g, "''"), which is SQL string-quote
doubling -- meaningless and actively corrupting for a value that is bound, so
any search containing an apostrophe matched nothing. And its \% escaping had
no ESCAPE clause on the LIKE, which is engine-dependent: honoured on Postgres,
a literal backslash on SQLite, so % and _ stayed wildcards there.
Now mirrors the correct implementation from 59666b59: escape \ % _ only, and
a new likeWithEscape(column) emits `col LIKE ? ESCAPE '\'`. Both call sites
move to whereRaw with the value still bound; the column argument is a literal,
documented in the JSDoc.
Callers checked before changing the contract: adminPhotos.js,
adminEvents/crud.js, and sqlSecurity's own addLikeCondition(), which has no
callers anywhere -- pre-existing dead export, updated to the new shape rather
than deleted.
Behavioural change: searches containing ' % _ or \ now return the right rows
instead of nothing. Case sensitivity is unchanged.
Refs testplan REPORT.md, escapeLikePattern finding.
D2 -- returnEmptyString: false. i18next defaults it to true, so an empty
translation was returned as valid and rendered as blank UI instead of falling
back to English. Verified safe first: zero empty-string values across all 8
locales, no addResourceBundle or runtime resource injection, no public/locales
for the HTTP backend, and the three t(key, '') call sites resolve against key
families fully populated in en and de.
D3 -- German formality normalised to Sie throughout, 101 strings. There is no
deliberate du island: Sie outnumbered du roughly 6:1 (~390 vs 65 addressed
strings), every namespace with more than ten addressed strings was
Sie-dominant, and the guest gallery plus all public/billing surfaces were
already 100% Sie. Even customer.*, the reported offender, was internally mixed
rather than consistently du. Two detection passes: du-pronouns (now zero) and
du-imperatives without a pronoun (Klicke…, Aktiviere…, Wähle…). Placeholders
verified mechanically unchanged. Left alone: ten 1st-person progress labels
(Lade Benutzer…, Prüfe…, Teste Verbindung…) -- those are label style, not
address, and normalising two of ten would have made it worse.
C7 -- removeUnusedKeys stays false, but the comment now carries measured
evidence instead of an estimate. The honest attempt was made: 61 preserve
globs derived mechanically from all 82 dynamic key templates in src (far more
than the 5 families previously named) plus 17 constant-table prefixes cut
removals from 422 to 158. Two things still block it. 47 of the remainder are
the base form of a plural key that src does pass to t(); i18next tries the
_other suffix first so nothing visibly breaks, but covering them needs a
literal pattern per key and forgetting one silently deletes a live key --
exactly the failure the flag prevents. And pruning is not idempotent: run for
real, extract had to run three times before --ci --dry-run came back clean,
each pass uncovering another removal, so i18n:ci would fail on a correct tree
until someone ran extract enough times.
Also adds the three settings.analytics keys that 9251745a referenced in
AnalyticsTab without adding (proxiedNotice, proxiedNoticeText,
customOnlyCspWarningText) -- en from the source defaults, de translated.
Refs testplan REPORT.md C7, D2, D3.
Two small fixes in one file.
The local `mode` at line 323 collided with the info-banner `mode` the i18next
TS resolver reads at line 490, so the extractor emitted four keys the code can
never request (events.infoBanner.mode_managed / mode_reference and the
promoBanner pair) -- the real modes are inherit|custom|off. Renamed to
sourceMode; the four phantom keys are dropped from the locale files.
Also bounds the Photo Limit input, the twin of the one fixed in e5f6085a:
min={0} with no max makes input[type=number] report aria-valuemax="0", and an
out-of-range value only failed at INSERT. Set to the events.photo_cap column's
real signed-32-bit ceiling.
Refs testplan REPORT.md B15 and the aria-valuemax warning.
With Accounting on and CRM/customerPortal off, the picker renders but there
was still no way to create the first customer: /admin/clients/accounts and
every CRM editor with inline-create are feature-gated, and the picker's
empty-state hint pointed at that unreachable page.
Reuses the existing InlineCustomerCreate that CustomerPicker already mounts
for the CRM editors. The affordance is gated on customers.create, matching the
backend, where POST /admin/customers is permission-gated and not flag-gated.
mode is 'passive' when customerPortal is off -- a portal invitation would
email a link to a login that does not exist -- and 'both' when it is on. On
success the customer is appended to the selection, which is what the
accounting call sites' next.slice(-1) already expects.
The noResults hint pointing at the hidden page is replaced by two keys: one
naming the button, one for admins without the permission.
Refs testplan REPORT.md B12.
A self-hosted Umami/Rybbit domain configured in Settings could never load: the
CSP script-src allowlist is static, and the earlier pass could only add an
admin-visible warning because nginx.conf:58 strips helmet's header and
location / serves the SPA document off disk via try_files -- so helmet can
never govern it in Docker. Verified by reading the config, not inferred; that
kills the "make helmet dynamic" option outright.
Rather than templating the CSP, the tracker is now same-origin. The script and
every endpoint it talks to are served from /api/analytics/tracker/* and
proxied server-side to the configured instance, so script-src 'self' and
connect-src 'self' already cover it. The CSP is unchanged: nothing to
template, no env var, no restart -- it takes effect when Settings is saved.
That also closes A3 structurally rather than by widening a directive.
Endpoint mapping taken from vendor sources, not guessed: Umami's
host || currentScript.src + /api/send, and Rybbit's documented
/track, /site/tracking-config/<id>, /site/<id>/feature-flags/evaluate.
data-host-url is set explicitly so a COLLECT_API_HOST-built Umami cannot
bypass the proxy. Session replay is deliberately NOT proxied: replaying
gallery pages would capture the share token (GHSA-7m6c).
nginx still needed one line, for a non-obvious reason: the static-asset regex
location outranks the plain /api prefix in nginx's matching order, so
/api/analytics/tracker/script.js resolved as a static file. Confirmed
empirically against a real nginx:alpine -- 404 before the ^~ block, 502
(proxied) after, with /assets/app.js and /api/public/settings unchanged.
The native SERVE_FRONTEND install needed no change; helmet already has 'self'
in both directives and the proxy mounts ahead of express.static.
Security boundary, since this makes the server fetch an admin-supplied URL:
closed per-provider path+method allowlist (4 paths), DNS-resolving
isHostAllowed blocking private/internal/metadata addresses in production
(matching the s3Storage prod-only precedent), base rebuilt as
origin + pathname so userinfo/query/fragment cannot smuggle anything,
redirect: 'error', cookie/authorization/referer/host never forwarded, an
HTML upstream response re-served as application/octet-stream + nosniff, and
64KB request / 2MB response / 5s timeout / 120rpm caps. X-Forwarded-For and
User-Agent are forwarded so geo and device attribution survive.
Residual, stated plainly: an unauthenticated rate-limited relay to one
admin-chosen public host on 4 paths, and TOCTOU DNS rebinding is unmitigated
as it is elsewhere in the repo.
The Umami and Rybbit panels now explain they are proxied; the Custom panel
keeps a CSP warning -- it is the one mode with nothing to proxy -- naming both
script-src and connect-src.
Refs testplan REPORT.md A2, A3.
The "hidden photo has no indicator on the admin grid" warning was not a
missing badge. The badge markup has existed since #172; the defect was in
GET /:eventId/photos, which hand-builds its response literal field by field
and never emitted `visibility` -- so the value was always undefined and
neither the grid tile nor the list row badge could render. Same omission class
as the view_count/download_count bug already commented in that file.
(The `visibility` line itself was swept into 4721bd83, whose message does not
mention it -- recording that here.)
Fixes the adjacent instance too: `processing_status` is missing from the same
mapper, so the grid's "Processing…" and "Failed"/Retry placeholders could
never render either.
On the card, reuses the existing EyeOff badge pattern from the list-view rows,
adds a tooltip on both layouts, and drops the category badge to top-9 so the
hidden badge can own the top-left corner.
Also fixes the Photo Limit spinbutton's aria-valuemax, which read 0 even with
a real cap set. Root cause: min={0} with no max -- for input[type=number]
Blink's MaxValueForRange returns DBL_MAX, fails isfinite and supplies no max,
so a11y tooling prints the default 0. Set to 2147483647, the events.photo_cap
column's real signed-32-bit ceiling (migration 074), which also stops an
out-of-range value failing only at INSERT. The sibling expires_in_days input
already had proper bounds.
Known: EventInformationCard carries the identical Photo Limit input with the
same defect; it is held by another concurrent change and follows next.
Refs testplan REPORT.md, hidden-photo and aria-valuemax warnings.
A generic shell heading stacked on top of each tab component's own internal
heading. The report named five tabs "at least"; auditing all 28 found 11:
downloads, sso, apiTokens, webhooks, businessProfile, crm, accounting,
whatsapp, slideshow, moderation, styling. On the first eight the two headings
resolve to the identical string -- sso and businessProfile literally render
the same key twice. The other three were near-identical stacked titles
("Moderation"/"Word Filters", "Custom CSS"/"Custom CSS Templates",
"CRM behaviour"/"CRM settings").
Clean, and left alone: general, events, categories, thumbnails, security, seo,
imageSecurity, status, analytics (its first heading is a genuine sub-section),
plus the eight already in TABS_WITH_OWN_HEADER.
Removed the component side and kept the shell heading: the shell heading is
the consistent one (icon + label + divider on ~20 tabs) and always matches the
nav item the admin clicked, and none of these components are mounted outside
SettingsPage, so nothing loses a title. Subtitles and intro copy preserved
throughout; orphaned icon imports removed.
The guard test was checked against the pre-fix blobs and does fail on them.
Refs testplan REPORT.md, "duplicate H2 section heading" warning.
SystemHealthPage, CrmOverviewSection and HoursSection used text-theme
(color: var(--color-text)) explicitly, so on a dark-toned branding theme they
render near-invisible on the light admin background -- and because the class
is explicit it beats the AdminLayout default that protects everything else.
Converted to the neutral scale using the convention from da9ceb14. Swept the
whole of each file rather than the cited lines: 3 in SystemHealthPage, 5 in
CrmOverviewSection (two of them h3 elements with no colour class at all), 17
in HoursSection. All three are admin-only -- call sites verified as
AdminDashboard, CustomerDetailPage, HoursLoggingPage and /admin/system-health
-- so no customer-portal or public-token surface is affected, where these
utilities are correct by design. Zero themed utilities remain in the three
files.
Refs testplan REPORT.md B14.
Block names ellipsized to ~4-6 characters ("Vertr...", "Bildr..."). The
tooltip added earlier made them recoverable but the list still was not
scannable.
The file's "intentionally mirrors EmailConfigPage's Templates tab" comment was
the reason the ratio was left alone. Re-evaluated: both pages render in the
same Settings shell so the ratio is shared, but the content is not. A block
tile spends a fixed ~105px of its row on the "System" badge plus the n/6 pill,
and block names are long German noun phrases; EmailConfigPage's tiles carry
one badge and short display names ("Gallery Created"). So the shared ratio is
not simply wrong -- it is wrong here. Diverged only here: lg:grid-cols-3 /
col-span-2 becomes lg:grid-cols-5 with a 2/3 split (40/60). Mobile stack
untouched, and the comment now names the divergence and why.
Refs testplan REPORT.md A5.
B6 -- seven gallery routes returned private, per-guest data with no
Cache-Control at all, relying on heuristic freshness. noStoreCache is mounted
per route rather than on the router, because the media routes set their own
private, max-age=1800/3600 and must keep it. Covered: /photos (own
likes/favourites/ratings, hidden photos for a client token), /people, /stats,
/verify-token/:token (an authorization decision -- a cached {valid:true}
outlives a rotated token), /show/:token/session (the response IS a credential;
it mints a gallery JWT), /show/:token/state and /download-jobs/:token (live
polls, where a cached "preparing" strands the caller). Deliberately untouched:
the photo/thumbnail/hero/preview and css-template routes, which set their own
caching, the binary downloads, and /info + /resolve, which are unauthenticated
public metadata rather than per-guest private.
ETag/304 revalidation is intact and pinned by a test: no-store stops the
browser retaining the body, not express agreeing an unchanged payload is
unchanged. That matters because the post-upload poll depends on it.
B7 -- the guest upload flow had no progress signal, so the UI polled the photo
list blind and gave up after 60s with no explanation. Adds
GET /:slug/uploads/status?ids=... rather than pending counts in the photos
payload: counts there are event-wide, so another guest's or the admin's stuck
upload would spin the notice forever and it could never say "your photo
failed".
Authorization: verifyGalleryAccess already resolves req.event from the
caller's token, and the query is scoped `.where('event_id', req.event.id)`, so
an id from another gallery matches no row -- neither a cross-event read nor an
existence oracle, since it returns all-zero counts rather than a 403/404 that
would confirm the id exists elsewhere. Slideshow tokens are denied (a kiosk
never uploads). Ids are pattern-validated, max 50. The response is counts
only: no filenames and specifically no processing_error strings, which can
carry internal paths. Not gated on allow_user_uploads, so an admin flipping
the toggle mid-flight does not strand an in-progress guest.
The frontend now finishes on the real terminal condition, refetches as each
photo lands rather than only at the end, shows a processing pill, and reports
real failures instead of silently timing out.
Refs testplan REPORT.md B6, B7.
Four related fixes on the admin upload/photo path.
B5 -- PATCH /photos/:photoId and POST /photos/bulk-update took any
parseInt(...) > 0 straight into the update with no existence or scope check,
so a photo could be moved into another event's category. The upload route
already validated `event_id = X OR is_global` per #500/#525; extracted that
query as findScopedCategory() and used it on all three routes so the 400 body
is byte-identical. 0/negative/'individual'/'collage'/null still clear without
a lookup, so the clear path costs no extra query.
B9 -- three distinct temp-file leaks, not one. The validator's size branch
never unlinked; the cleanup lived in the final handler, unreachable on any
400; and multer's `destination` callback runs per file and overwrote
req.tempUploadPath, so even the success path only ever removed the last
file's directory. Now: discardUploadedFiles() runs on every 4xx and the 500
(ENOENT tolerated, and files are only dropped when the whole request is being
rejected, so the passing path is untouched); cleanup registered before multer
so it also covers multer's own LIMIT_FILE_SIZE return; one directory per
request.
B8 -- the admin uploader filtered on MIME only, so an oversized file was
uploaded in full before the server's 400. Mirrors UserPhotoUpload's existing
per-file toast-and-drop.
C4 -- general_max_file_size_mb was a single cap for photos and videos, so the
50MB default meant admins could not upload ordinary video without also
raising the photo limit. Adds general_max_video_size_mb (default 500MB,
clamped by the same 10GB MAX_ALLOWED_FILE_SIZE_MB ceiling, read per request,
60s cache), editable in Settings -> General.
Photo uploads are protected from regressing by keeping multer's type-blind
limit at max(photoCap, videoCap) and moving the per-kind decision into
validateUploadContent, where file.mimetype exists. It 400s with the existing
message shape, so an oversized photo is still rejected with the identical
body it produced when multer did the rejecting.
Known gap: chunked-upload/init still applies the photo cap to video. Making
it video-aware would change an existing assertion that pins a 200MB video
init being rejected under a 1MB general cap. No component calls that path
today and the direction is strict rather than a bypass, so it is left as-is.
Guest video uploads still share the single cap in gallery.js.
Refs testplan REPORT.md B5, B8, B9, C4.
IMMUTABLE_EVENT_COLUMNS is documented as a COMPLETE deny-set that new
server-managed columns must be added to. archive_size is written by
archiveService from the zip's real byte count and is what the archives list
now sorts and displays, so an events.edit holder could otherwise set a
cosmetic size on a non-archived event.
Follow-up to 59666b59, which added the column.
ownership.js caught a lookup failure, returned 500 and logged nothing -- the
file had no logger import, so a failing ownership check was invisible in the
logs. Added logging matching photoAuth.js/permissions.js
({ error, stack } plus the relevant id), response behaviour unchanged. Fixed
both swallowed catches: requireEventOwnership, the reported one, and the
byte-identical requireProjectOwnership.
Also removes AdminAuthContext.updatePasswordChanged, now dead -- superseded
by the deliberate full-page reload in onSuccess, with zero callers left.
setMustChangePassword and mustChangePassword stay; nothing else orphaned.
Refs testplan REPORT.md B13, B16.
VALID_QUOTE_TRANSITIONS was a complete-looking quote state machine that
nothing consulted, so status changes were unvalidated.
Mapping every writer of quotes.status (quoteService.js is the only one --
dealsService, projectService, adminDashboard and customer.js all read) showed
the table itself was wrong: six legitimate transitions were missing.
sendQuote allows draft/declined/expired -> sent but the table had draft only;
adminAcceptQuote allows draft/sent/expired but had sent only;
adminDeclineQuote allows draft/sent/expired but had draft/sent; recordResponse
had no same-status entry. Enforcing it as written would have broken
accept-on-behalf from a draft, resend-after-decline, every expired revival and
the 15-minute response-toggle window.
So the table is reconciled to reality first, then assertQuoteTransition()
(409, QUOTE_INVALID_TRANSITION) is called at all seven sites.
Two things worth carrying forward. Nothing in the codebase ever sets
'expired' -- the header comment says "set by the scheduler" and there is no
such scheduler; sent -> expired is retained as documented intent only. And
the backstop's added value is narrow: every reachable invalid transition is
already caught by a call site's own better-worded guard, which fires first.
What it newly catches is a status the machine has never heard of -- a legacy
or corrupt row like 'cancelled' sails through adminAcceptQuote's guard, which
only excludes accepted/declined/converted, and used to be silently
overwritten. That is what the new tests pin.
Refs testplan REPORT.md B4.
`booted` was assigned but never read, so the guard's early return was missing
and the builtin workflow seeder ran on every call.
Impact was wasteful, not harmful: seedOneBuiltin is idempotent -- it keys on
builtin_key and returns early when adminOwned or storedVersion >= def.version,
writing a graph only on a fresh insert or a version bump. So repeat calls cost
a lookup per builtin plus a graph rebuild, with no duplicate rows.
`booted = true` stays inside the try, so a seed that never got off the ground
(workflows table not migrated, DB down) leaves the flag clear and retries. A
per-builtin failure is still swallowed by the inner catch and does not block
the flag, unchanged.
Restoring the guard broke workflowEngine.test.js, which calls the boot seeder
seven times in one worker and needs the second call to run in two of them.
Followed the existing _backupPathsBoot/_restoreSettingsBoot precedent:
exported _resetBootForTests().
Refs testplan REPORT.md B3.
D4 audit found defaultTemplateKeys was worse than stale sample data:
- Only .name was ever read. The subject/body/variables triple on each entry
was dead data -- and it is where {{password}} and {{expiration_date}}
originated, neither of which exists in any shipped template (they are
gallery_password and expiry_date).
- It covered 4 keys out of ~40. A fresh install already carries 17 templates,
and ~40 with the CRM flags on. Every key not in the list rendered its raw
snake_case template_key as its display name in both the sidebar and the
read-only "Template name" field -- customer_gallery_assigned,
database_backup_completed, invoice_collections_handoff, all five
event_reminder_*, and so on.
Replaced with TEMPLATE_DISPLAY_NAMES covering every key from
migrations/core/*.js plus the crm/contract/eventReminder template services,
falling back to the raw key. Drops the now-orphaned password sample value.
Adjacent drift found, not fixed (different const, and fixing it would be
scope creep): eventReminderTemplates.js inserts with category 'crm' /
subcategory 'event_reminder', neither of which is in CATEGORY_ORDER or
CORE_SUBCATEGORY_ORDER, so all five reminder templates fall through the
unknown-category fallback into core -> "other". They are visible, just filed
in the wrong bucket.
Refs testplan REPORT.md D4.
Correction: the reported premise held for only one of the three templates,
verified by running the core migration set against an empty database.
- expiration_warning is German-is-English on every fresh install, exactly as
reported. Repaired with migration 194's pattern verbatim.
- gallery_expired and archive_complete are NOT German-is-English -- they do
not exist at all. Their master rows are inserted only by migrations/legacy/
010+020, which never run on a fresh install, so 075/099/106/108 seeded zero
translations for them (they key off a master row that is not there). A
fresh install's email_templates holds 17 keys and neither is among them.
The consequence is worse than a translation gap: expirationChecker's
sendGalleryExpiredEmails and archiveService's completion mail both hit
"Email template not found", retry three times and die silently in
email_queue on every expiry and every archive.
So 195 also seeds those two (master row + en/de translations + category),
but only when the master row is absent -- it never overwrites. English
follows legacy 028, which emailProcessor's own comments call the shipped
copy; German follows legacy 026's wording. Both are restructured into the
plain unstyled shape the other core-seeded templates use, so wrapEmailHtml's
configurable palette governs styling rather than hard-coded hex. The
support-contact line is wrapped in {{#if support_email}} because
getSupportEmail() can return ''.
196 adds the {{#if welcome_message}} block that nl/pt/ru/fr/es/sl already
have in gallery_created but en and de lack, so the photographer's personal
note was silently dropped for those two locales even though the value is
passed at send time. safeTemplateReplace does resolve {{#if}} before variable
substitution, so this is a real conditional -- there is a test rendering the
migrated body both ways. HTML body only, matching the other locales:
emailProcessor rewrites welcome_message through formatWelcomeMessage
(escape + nl2br) once for both bodies, so the text part would print literal
<br /> and &.
Both migrations keep 194's conservative condition -- rewrite only while the
German is still byte-identical to English or empty -- so admin-edited and
legacy-translated installs are untouched. Idempotent, guarded, no-op down().
Known gap, documented in 195's header: the two newly seeded templates get
en/de only. nl/pt/ru/fr/es/sl fall back to en via processTemplate's fallback
chain, which is strictly better than today's hard failure but is not real
localisation.
Refs testplan REPORT.md B1, B2.
Correction to the reported cause: both create paths already loop
`while (await db('events').where({ slug }).first())` before inserting, so a
sequential duplicate never 500s -- it gets -1 appended. The 500 is purely the
read-then-insert race: two concurrent creates for the same name+date both
clear the check and the loser's INSERT trips events_slug_unique.
isDuplicateSlugError(), built on the existing utils/dbErrors.isUniqueViolation,
is wired into the catch of POST / and POST /:id/duplicate ->
409 { code: 'EVENT_SLUG_TAKEN' }. The predicate is deliberately narrower than
isUniqueViolation: on PG it matches err.constraint, on SQLite the specific
"UNIQUE constraint failed: ... events.slug" text. A loose message test would
misfire because knex prefixes the whole INSERT -- which always names slug --
to err.message, and events has other unique columns (share_token).
PUT /:id cannot collide: slug is in IMMUTABLE_EVENT_COLUMNS. No other
adminEvents sub-router writes slug. CreateEventPage already toasts data.error,
so no frontend change is needed.
The test makes the race deterministic without timers: it hooks knex's `query`
event and injects the colliding row the instant the route issues its
slug-existence SELECT. The route then spends a full bcrypt hash before its own
INSERT, so the injected row always lands first.
Refs testplan REPORT.md B10.
Closes the three trade-offs the server-side archives query deliberately
accepted.
C1 -- the sorted number and the displayed number are now the same one.
There was no archive_size column, so the Size column came from a per-row
fs.stat done after pagination while the sort fell back to summed photo bytes:
the list could be ordered by a number the user was not looking at. Adds
events.archive_size (bigInteger -- int4's 2.1GB ceiling is the same limit
that forced the restore path off adm-zip), written at archive time from
archive.pointer(), which is the exact byte count the completion email already
reports. The route now sorts and displays that column and no longer touches
the filesystem. The migration backfills by stat-ing every archive_path where
the column is null, outside the column guard so a half-finished run
self-heals; unstatable rows (missing zip, S3-backed storage) stay null, order
last via COALESCE and display 0 -- exactly what the old fs.stat produced for
a file it could not read. Restore nulls it alongside archive_path.
Accepted: the list no longer notices a zip deleted out of band and shows the
last recorded size. The detail route still stats the real file.
C2 -- escape \ % _ in the bound value plus an explicit ESCAPE '\'. The
ESCAPE clause is load-bearing rather than decorative: SQLite has no default
LIKE escape character, so without it the escaped pattern matches literal
backslashes and the search silently returns nothing on SQLite while working
on Postgres. The value stays bound; no interpolation.
C3 -- the four stat cards aggregated only the current page, so every total
was wrong for any dataset past page one. The list response now carries
totals { archives, photos, archiveSize } computed with the same applyFilters()
closure as pagination.total, so cards and footer cannot drift. Two aggregate
queries: archive_size sums on the unjoined events query (joining photos
multiplies it by photo count) and photos count on the joined one, both read
back through Number() for pg's bigint-as-string. The "Showing X of Y" line
moved out of the totalPages > 1 guard so it survives a single-page result,
now gated on total > 0 so a zero-result search does not render
"Showing 1 to 0 of 0"; only the page controls stay conditional.
Test fixtures deliberately order zip sizes differently from summed photo
bytes, so the sort test can only pass on the right column.
Refs testplan REPORT.md C1, C2, C3.
StoryFeedbackSheet could never open: handleOpenFeedback was the only caller
of setSelectedPhotoForFeedback and was itself never called. This was the last
remaining build:check error (TS6133).
Removed rather than wired up, on three findings:
- The sheet offered nothing PhotoLightbox does not, and was strictly worse.
It held comments and ratings in layout-local useState and never called
feedbackService.getPhotoFeedback, so existing server-side feedback was
invisible; it rendered stars and a comment form unconditionally, ignoring
allow_ratings/allow_comments; and it had no reactions, colour labels,
identity modal or rate-limit handling. This layout already renders
PhotoLightbox with feedbackEnabled, which does all of that against the
server.
- It was not a mobile affordance. The CSS styled it as a fixed right-edge
desktop drawer (right: 0; max-width: 28rem) with no media query.
- Every sibling layout routes feedback through the lightbox. Grid, Masonry,
Timeline, Mosaic and Carousel expose a per-card onQuickComment that calls
onOpenPhotoWithFeedback to open the parent's lightbox on the feedback tab;
none has a standalone feedback surface. The closest sibling,
GalleryPremiumLayout, renders its own lightbox and deliberately voids
_onOpenPhotoWithFeedback with no per-card control -- exactly the shape
Story now has.
Drops the component, its state and handlers, the feedbackOptions destructure
(only the sheet read it) and 251 lines of orphaned CSS. savedIdentity also
fed guest_name/guest_email into the like call; those were always undefined at
runtime since the unreachable sheet was their only writer, so no behaviour
changes.
Also widens the Story nav search input, which clipped its placeholder. At the
input's computed 14px the placeholder measures en 121px, de 145, ru 152,
fr 174 against a 128px box -- so German was 17px over and French 46px over.
8rem -> 13rem collapsed, 12rem -> 17rem focused, keeping expand-on-focus;
verified at 1280px and at the 768px breakpoint where the search appears.
Refs testplan REPORT.md A1 and the gallery-story placeholder warning.
innerHeight is in page CSS pixels and shrinks under browser zoom;
outerHeight does not. At 150-200% zoom a normal window therefore shows an
absolute outer/inner gap of 400-500px, past every threshold, so an
accessibility zoom read as a docked DevTools panel and - at protectionLevel
"maximum" - redirected the guest off the gallery on load. Pre-existing
(the previous threshold was 100px), but the rewrite kept the shape.
The gap is now measured relative to a baseline taken at mount, and the
baseline is re-taken whenever devicePixelRatio changes, which a zoom step
does and a docked panel does not. Only a gap that grows past the threshold
at a constant ratio counts. The mount-time check is dropped: a panel that
is already open at load is indistinguishable from a zoomed window.
The init route checked the client-declared fileSize against
general_max_file_size_mb, but nothing checked what then came through the
chunk route: a client could declare `fileSize: 1` and stream any amount,
and completeUpload only logged the size mismatch before handing the merged
file on. The cap the earlier commit added at init was therefore a gate with
no fence.
The service now carries the cap from init and enforces it on the running
byte total per chunk (aborting the upload once crossed, since the chunks on
disk are already over the limit), rejects chunk indices outside the
announced range, and re-checks the merged file as a backstop. Both routes
answer 413/400 for these instead of a blanket 500.
The block level is advertised as "comment is rejected immediately", but every
non-approved comment was saved with is_approved = false instead of the
submission being refused.
moderateText now sets an explicit `blocked: true` on the blocking-violation
branch -- branching on the reason string in the route would have been fragile
-- and the route 400s with code COMMENT_BLOCKED and stores nothing. Everything
else that is not approved (moderate/high, the spam and caps checks, and the
"Moderation system error" fallback) deliberately omits the flag and keeps the
held-for-moderation path, so a moderation failure still fails safe.
Also fixes an adjacent defect that made the tier split unobservable:
feedbackService.submitFeedback ignored feedbackData.is_approved entirely and
hard-derived is_approved from moderate_comments. So a moderate/high
word-filter hit on an event with moderation switched OFF was published
immediately -- the route's `feedbackData.is_approved = false` was dead code.
Now honoured one-directionally: a caller-supplied false is respected, but
nothing a caller passes can RELAX the event's setting. That deliberately
leaves the route's reputation.autoApprove -> is_approved = true branch inert
rather than letting a trusted guest bypass an event's moderation setting.
Refs testplan REPORT.md B11.
(cherry picked from commit b1b57b1615aaf02fe76e789a86b7e11933288d77)
Codex review round 1 on #1266.
Migration 194 gated all three German fields on body_html alone, so an admin
who had translated only the subject would lose it the moment the HTML still
matched English -- and down() is a deliberate no-op, making that loss
unrecoverable. Each field is now judged independently, for both the
translations table and the legacy _de columns.
Archives search escapes LIKE wildcards. % and _ are literal characters to the
client-side includes() this replaced but wildcards to LIKE, so searching
"100%" matched every archive and reported a nonsense total. The ESCAPE clause
is load-bearing: SQLite has no default LIKE escape character, so without it
the escaped pattern matches literal backslashes there while working on PG.
The post-upload poll waits for every queued file. Each is processed
independently, so stopping at the first new photo left the rest of a
multi-file upload hidden until a manual refresh -- the exact symptom the
polling was added to prevent. UserPhotoUpload now reports how many files the
server accepted.
(The latter two are superseded by stronger fixes in #1267 -- the upload-status
endpoint and the shared escape helper -- but each PR has to be correct on its
own.)
74 errors -> 1. No suppressions: zero `any`, `as unknown as`, `@ts-ignore` or
non-null `!` added, and tsconfig is untouched. Each error was triaged as
"the type is wrong" vs "the code is wrong" and fixed on that side.
Live bugs the checker was pointing at:
- admin.service.ts TS1117 duplicate key: admin_password_reset was defined
twice and the later one won at runtime. Removed it so the earlier entry
wins, which matches the actual emitter in userManagementService.js and
carries the email fallback.
- PhotoGridWithLayouts dropped allowReactions from its prop type, so the
Premium layout's reactions never activated even though GalleryView passes
it and GalleryPremiumLayout reads it.
- SlideshowPage's poll never copied `order` into next/prev, so live
play-order changes never reached a running kiosk.
- CustomerLayout compared branding_force_color_mode against 'auto', which is
never persisted (only 'dark'|'light'|null), so the customer portal always
picked the light logo even in OS dark mode.
- EmailConfigPage rendered lang.flag, but SUPPORTED_LANGUAGES exposes Flag, a
component -- so nothing rendered. And editing a language with no translation
yet spread undefined, storing a partial object missing required fields.
- publicQuotes.js projected only 6 line-item fields, omitting
parentLineItemId/parentPosition/detailsText, so the migration-119 sub-item
hierarchy and details text could never render on the customer-facing quote
page -- the frontend code for it was unreachable. It reads from the same
quoteService.getQuoteById the admin route uses, where those fields are
present; adminQuotes.js projects all three. Fixed the projection rather
than adding fields to the frontend type, which would have compiled while
leaving the feature broken.
- DuplicateEventDialog's helper text was silently dropped: LocalizedDateInput
had no helperText prop. Added, mirroring Input.tsx incl. aria-describedby.
- ThemeEditorModal/EventThemeSection still passed isPreviewMode, a prop
822be9a9 deliberately removed but missed at these two call sites.
- GalleryPage's hero-photo injection was dead: /gallery/:slug/info does not
return hero_photo_id (only /photos does) and GalleryView already does it
correctly. Removed the dead block rather than adding a field the API
never sends.
Stale types corrected against the backend route that produces each payload:
GalleryInfo (allow_downloads, allow_user_uploads), GalleryData.event
(download_zip_ready), UpdateEventData (client_access_enabled, client_password,
regenerate_client_token), InvoiceSummary (replacesInvoiceId), ExportOptions
(mark_source, plus a snake_case ExportFilter matching the actual wire format),
customer.service contracts, AdminUser timestamps widened to string|null,
formatMoney currency widened to match its own (currency || 'CHF') guard,
faceCropStyle dimensions widened to match its !photoWidth guard, DEFAULT_FLAGS
faces, logo_position 'sidepanel', and the hand-rolled t() props replaced with
i18next's TFunction in four files.
Unused symbols were checked before deletion; UpdateInstructionsDialog's
targetVersion prop was completed rather than deleted (declared and passed but
never rendered -- now the fallback before the query resolves).
Left unfixed, deliberately: GalleryStoryLayout's handleOpenFeedback (TS6133).
It is the only caller of setSelectedPhotoForFeedback and is itself never
called, so StoryFeedbackSheet can never open on the Story theme. Wiring it
needs a new affordance on StoryPhotoCard (no sibling layout exposes one to
copy) and deleting it would orphan the sheet -- a product decision, not a
type fix. Note PhotoLightbox on the same layout already handles feedback,
so the sheet may simply be superseded.
Refs testplan REPORT.md #22 (Part 1.3.04).
exit 1 -> exit 0.
Three findings, none of which matched the reported symptoms.
1. The two "unparseable .d.ts files" are not malformed. RestoreWizard.d.ts and
BackupHistory.d.ts are valid declaration files sitting next to their .jsx
implementations; i18next-cli feeds them to SWC as ordinary .ts modules with
no ambient flag, where an uninitialised `const` is a hard syntax error. They
should never have been scanned at all. Root cause is the input glob:
i18next-cli passes `input` straight to `glob`, which does NOT honour
`!`-prefixed negation inside the pattern list, so
'!src/**/*.{test,spec,d}.{ts,tsx}' was a silent no-op and all four .d.ts
files plus 57 test files were being scanned. Moved the exclusions to
extract.ignore, where they take effect; the extracted key set is unchanged.
2. The "missing French keys" were not English-vs-French drift. The extractor
wanted to add ~2771 keys to fr.json with value "" -- and src/i18n/config.ts
does not set returnEmptyString, whose i18next default is true, so those
empty strings would be returned as valid translations and render as blank
UI rather than falling back to English. Filling nl/pt/ru/fr with ~11000
empty strings would have been a worse regression than the failing check.
The check was demanding parity for locales this project deliberately keeps
partial, so `locales` is now ['en','de'] -- the two actually kept at parity.
nl/pt/ru/fr join sl/es as hand-maintained partial locales on
fallbackLng 'en'. No French was written.
3. de.json is a parity locale, and the extractor legitimately found 307 keys
missing from both en and de (shipped t() calls never added to the locale
files). Rather than accept 307 blank German strings these were written by
hand: 105 are _one/_other variants derived from existing German bases with
correct singular/plural, the rest translated against each section's register
(Sie on admin/public-billing surfaces, du in the customer portal to match
customer.quotes/customer.bills) reusing terms already established in de.json.
Verified: 0 interpolation-placeholder mismatches between en and de across
all 308 new keys, 0 empty and 0 key-shaped values remaining, and the diff is
strictly additive (en +308, de +307, 0 removed, 0 changed).
removeUnusedKeys is now false, replacing the dead preservePatterns: []. It
wanted to delete ~355 live keys per locale across ~90 prefixes -- families
built at runtime (admin.activities.*, admin.notificationMessages.*,
projects.status.*) or held in constant tables the extractor cannot resolve
(AdminSidebar nameKey, CrmDevelopmentPage titleKey/descKey). Covering them
would need ~30 wildcards spanning most of the key space; disabling pruning is
the same behaviour, honestly stated, with the call sites named.
Refs testplan REPORT.md #22 (Part 1.3.03).
The Blocks list column ellipsizes names to ~4-6 characters ("Vertr...",
"Bildr...") with no title attribute, so the list is unscannable without
clicking into each block. Add title on the name and description, plus min-w-0
so the name shrinks instead of pushing the badges out.
Did not widen the column: the file carries an explicit design-intent comment
that its two-column grid "intentionally mirrors EmailConfigPage's Templates
tab", and changing the span would break that deliberate parity. The tooltip
resolves the reported unscannability on its own.
Refs testplan REPORT.md #21 (Part 8, S13).
Users were shown raw "{{quoteNumber}}", "{{name}}" and "{{count}}" tokens.
Two distinct render-side causes; nothing is persisted as a rendered string
(messages are stored as type + metadata JSON and formatted client-side), so
no backend change was needed.
{{quoteNumber}} / {{name}} -- AdminDashboard's getActivityMessage built a
hardcoded five-value allowlist (eventName, email, count, template,
categoryName) and passed it to t('admin.activities.<type>'). The backend does
record quoteNumber (quoteService.js) and name (adminWebhooks.js); the values
just never reached i18next, so every activity string interpolating anything
outside that allowlist rendered its literal token. Spread activity.metadata
first, keeping the five derived entries as overrides since they resolve from
columns that are not in metadata. Extracted as buildActivityParams for
testability, mirroring the formatDayHeader extraction.
{{count}} -- different cause, the notification-bell path: archiveBulk.js logs
successfulCount, but the locale string expects count and
bulk_archive_completed had no explicit case, so the default branch spread a
metadata object without one. Added a case next to the existing
bulk_delete_completed, following that idiom.
Also fixes bulk_delete_completed, which has the identical mismatch: it reads
metadata.deleted || metadata.count while archiveBulk.js writes successfulCount,
so that notification always rendered "0 events deleted". It degrades to a wrong
number rather than a visible placeholder, which is why it was not among the
three reported instances -- but it is the same one-token bug.
Refs testplan REPORT.md #15b.
Recurring pattern of components and strings shipped without translation
coverage, found across unrelated feature areas. +212 keys each to en.json and
de.json, provably additive (flattened-key diff: removed=0, changed=0;
formatting round-trips byte-identically).
Genuinely un-wired components (grep -c useTranslation == 0), now wired:
BulkArchiveModal (8 strings, count-pluralised), WebhookDeliveriesPage (27),
CMSEditor's TipTap toolbar/link dialog/status bar/help modal (64).
Hardcoded strings fixed in code: ImageSecurityTab's 4 spinbutton hints,
ProjectsListPage's unlocalized status enum.
Keys-only (component already calls t() correctly): General "Time format",
Branding Social Media + Promotional Banner, Quotes detail/editor, cms.showInFooter.
Two corrections to the report's attribution:
- BlockLibraryPage was NOT un-wired -- it calls t() on every string with
English defaults; all 32 contracts.blocks.* keys were simply absent from
both locale files, so everything fell back to the JSX default. Same for
ContractsListPage, where the report cited 3 missing keys and there are
actually 9 (all 5 table column headers plus the pagination line).
- CustomerDetailPage has full t() coverage; its single English "Contracts"
was a missing customer.nav.contracts key behind a dynamic labelKey.
Locale convention followed: i18next.config.ts manages en/de/nl/pt/ru/fr, but
only en and de are kept at parity (5198/5200 keys); the rest are ~50% partial
and rely on fallbackLng 'en'. Added to en + de only rather than inventing
212x6 unreviewable translations.
Also added the 25 missing businessProfile.* keys (PDF-letterhead section,
bank-accounts QR disclaimer). That component already calls t(), so those
strings localize as soon as the keys exist; no wiring needed.
Refs testplan REPORT.md #15a.
929 problems (928 errors, 1 warning) -> 0, exit 0.
Rule breakdown, which corrects the report's premise -- `indent` dominated, not
`quotes`: indent 719, quotes 68, no-unused-vars 54, no-empty 36,
no-useless-escape 22, no-case-declarations 17, no-inner-declarations 6,
no-control-regex 5, no-useless-catch 1, no-console 1 (warn).
--fix handled only indent + quotes (719+68 = exactly the "fixable" count).
no-useless-escape was NOT auto-fixable in this eslint version, so the one
genuinely risky class never went through the autofixer -- all 22 were done by
hand. Two mechanical proofs on the autofix diff: a token-level AST diff
(espree, before vs after) shows exactly 68 differing tokens, all quotes, with
the 719 indent fixes producing zero token changes; and a cooked-value diff of
every string/template/regex literal shows 0 differences.
Regex escapes: eslint was correctly conservative and did not flag the
load-bearing ones -- \- in [^a-zA-Z0-9_\-\.] (unescaping makes an invalid
reversed _ -> . range) or in [!@#$%^&*()_+\-=...] (would become a + -> = range
silently matching ",-."). Every removal was a \/ \[ or \. inside a character
class; all 11 old/new pairs were brute-forced over 794 inputs with 0
mismatches.
Manual fixes: no-empty were all deliberate best-effort catches around activity
logging, annotated rather than restructured; no-case-declarations braced in
two adminBackup switches; no-inner-declarations converted to const arrows
after checking no call precedes the declaration and no this/arguments use;
no-control-regex and no-console got targeted disables with stated reasons;
one `catch (e) { throw e; }` wrapper removed.
Two unused bindings were near-misses worth noting: secureStatic.js's
`fullPath` is a path-traversal guard (safePathJoin throws on escape) and
restoreService.js's `backupManifest` is the throw-on-corrupt-manifest gate
before a rollback -- deleting either would have silently removed a check. Only
the bindings were dropped; the calls stay.
Two real bugs found and deliberately preserved with a comment plus a narrow
disable rather than deleted, since deleting would erase the evidence:
_workflowSeedBoot.js's `booted` is written but never read, so the intended
once-per-process guard is missing its early return and workflows re-seed on
every call; and quoteService.js's VALID_QUOTE_TRANSITIONS is a full state
machine nothing consults, so quote status changes are unvalidated.
Backend test suite: 253 suites / 2552 tests passing, 0 failures, before and
after.
Refs testplan REPORT.md #22 (Part 1.2.02).
Components that render headings with no explicit text-colour class inherit
`body { color: var(--color-text) }`, and the branding theme sets --color-text
on <html> app-wide -- so on a dark-toned theme they render near-invisible
(#f5f5f5 on #fff), including inside the admin panel in light mode.
Compliance-adjacent: /impressum and /datenschutz are two of the surfaces.
Convention copied from AccountingTab, the QA control that is visually
identical but not affected: h2 -> text-neutral-900 dark:text-neutral-100,
labels -> neutral-700/300, checkbox labels -> neutral-800/200, hints ->
neutral-500/400.
Fixed beyond the reported lines, after sweeping each file:
- LegalPage: the CMS prose wrapper and the single-segment 404 heading.
- CMSContentBlock: the multi-segment CMS 404 and the admin unknown-route 404
turn out to be the same component (App.tsx path="*"; there is no admin-level
catch-all). Its text already used var(--color-text); the actual defect was
.card hardcoding bg-white under themed text, so the surface was fixed, not
the text.
- SettingsBusinessProfilePage (11), CrmSettingsPage (15, incl. both shared
checkbox-label helpers covering ~20 rendered rows), ReminderTemplatesPage (7,
incl. text-theme/text-muted-theme on an admin page where they are wrong).
- The <select> elements on those tabs: Tailwind preflight sets color:inherit
on form controls, so they picked up the near-white body colour on a white
background. Same root cause, not previously reported.
Plus one line of defence-in-depth on the admin shell (AdminLayout): an
explicit text colour there stops the whole admin panel inheriting the themed
body colour. Components with their own class, including text-theme, still win.
Interpretation -- the robust fix was evaluated and rejected. Scoping the theme
tokens to gallery contexts is not feasible: the leak is deliberate product
behaviour (GlobalThemeProvider applies branding on every non-gallery page),
40 files read var(--color-*) with only 9 under components/gallery, and it
would break the customer portal, the public token pages, AdminLoginPage and
the Branding live preview. It also cannot be done at container level without
moving `body { color: ... }` and the whole .text-theme/.bg-surface/.card-themed
utility family, which are global by construction.
Known remaining instances, not converted: SystemHealthPage, CrmOverviewSection
and HoursSection use text-theme explicitly on admin surfaces, so they keep the
themed colour and stay affected. Outside the reported surfaces.
Refs testplan REPORT.md #14 (Part 8, S3/S4/S13).
AdminSidebar's root div carries a Tailwind `transform` utility for the mobile
slide-in, and per the CSS spec a transformed ancestor becomes the containing
block for position:fixed descendants. The modal renders inline inside
VersionInfo/AdminSidebar, so its `fixed inset-0` backdrop was trapped in the
256px sidebar column (measured 256 vs window 1440) -- copy buttons overlapping
text, content truncating.
Reuse the codebase's one existing portal convention, from
gallery/FeedbackLimitReachedModal: assign the JSX to a const and return
createPortal(node, document.body).
Checked for other modals with the same trap; there are none.
UpdateInstructionsDialog is also fixed inset-0 but is mounted from
AdminDashboard inside <main>, and CustomerLayout has an identical transformed
aside with no modal inside it.
Refs testplan REPORT.md #13 (Part 3, B.07).
The preview modal's hardcoded sampleData had drifted from the templates'
declared variables arrays: it carried `password` and `expiration_date` and no
`host_name` at all, so {{host_name}}, {{gallery_password}} and {{expiry_date}}
rendered as literal placeholders in the gallery_created preview while
event_name/event_date/gallery_link substituted fine.
Derive the key set from the template's own `variables` instead, so nothing can
be missing again. editedTemplate already carries the array at the call site,
so no plumbing was needed. A small module-level lookup keeps sensible shapes
for the ~11 variables where shape matters (dates look like dates, links like
URLs), with a readable [name] fallback for anything uncurated -- curating all
~60 distinct variable names across the ~32 template seeds would just recreate
the drift trap.
Preview-only; real sent mail was never affected.
Refs testplan REPORT.md #17 (Part 3, J.04).
translations.de for gallery_created was the English copy word for word, while
nl/pt/ru/fr/es/sl are all localized. This is the mail sent on every gallery
creation, so German-default installs have been silently mailing English.
Root cause chain, fresh installs only: 001_init seeds the English template;
059 introduces the multilingual columns and fills subject_de/body_html_de/
body_text_de from their _en counterparts (its own comment: "Copy to German as
default"); 075 then materialises exactly those columns as the `de` row. The
real German only ever existed in migrations/legacy/026, and run-migrations.js
runs core/ only for fresh installs -- so every install created since 059 has
the English-as-German row.
A code-only fix would have changed nothing: knex will not re-run 059/075, so
existing installs would keep the bad row forever. Fixed as a content migration
following the repo's precedent for template repairs (094, 172).
Conservative about what it touches: the German row is rewritten only while it
is still byte-identical to English (or empty) -- precisely the broken state --
so a legacy install whose German came from 026, or any admin-edited template,
is left alone. Also repairs the legacy _de columns, which are still
emailProcessor's fallback path. Idempotent, hasTable-guarded, no-op down()
(reverting would restore English-as-German).
Placeholder parity with the English original is exact and test-asserted:
host_name, event_name, event_date, gallery_link, gallery_password, expiry_date.
Two related gaps found but deliberately not fixed, both outside the reported
bug: expiration_warning, gallery_expired and archive_complete are German-is-
English on fresh installs through the identical 059 mechanism (legacy 026
fixed all four). And nl/pt/ru/fr/es/sl additionally wrap a
{{#if welcome_message}} block that the English original lacks, even though
welcome_message is passed at send time -- so EN and now DE drop the
photographer's personal note. That is an English-side gap needing its own
decision.
Refs testplan REPORT.md #16 (Part 3, J.04).
dayHeaderContent assumed arg.date is always the real column date. It is in
the time-grid views, but FullCalendar v6 fills it from an internal reference
week (1970-01-04..10) for dayGridMonth headers, so the month header read a
fixed "Mo 05.01. ... So 04.01." regardless of the visible month. Body dates
were correct; only the header row was wrong.
Interpretation (flagged as ambiguous): a month-view column header labels seven
generic weekday columns shared by every week in the grid -- it has no single
date, so forcing one in is wrong by construction rather than just mis-computed.
Month view now renders the localized weekday alone, which is also FC's own
default there; timeGridWeek keeps weekday + DD.MM. since each column really is
one date.
Branches on view.type === 'dayGridMonth' exactly, not a dayGrid prefix:
dayGridWeek/dayGridDay do have real per-column dates and a prefix match would
break them if either is ever added.
Extracted to an exported formatDayHeader so it is testable without mounting
the page; the test mounts a real FullCalendar in both views, so an upgrade
that changes the arg.date contract fails rather than silently regresses.
FullCalendar dependency untouched.
Refs testplan REPORT.md #10 (Part 8, S9).
ArchivesPage fetched one 20-row page and then filtered and sorted only that
array in memory, while "Showing X of 802" / "Page 1 of 41" kept reporting the
full unfiltered count. Searching for an archive that exists but is not on the
current page returned a false "0 results" with no hint the search was
page-scoped.
The backend did not support the params (it read only page/limit and hardcoded
orderBy archived_at desc), so all three are new. Follows adminEvents/crud.js
for the shape and customerAccountsService for the case-insensitive predicate:
whereRaw with a bound parameter, never interpolated, and sortBy whitelisted to
date/name/size before it reaches orderBy. The same applyFilters() closure runs
against both the count query and the row query, so the total cannot drift from
the rows again.
Frontend mirrors EventsListPage: 300ms debounce, reset to page 1 on any query
change, placeholderData so keystrokes don't flash the spinner.
Two interpretation calls:
- sortBy=size orders by summed photo bytes, not the zip's on-disk size. The
Size column comes from a per-row fs.stat done after pagination and there is
no archive_size column, so a global sort on the real zip size would stat all
802 files per request. Ordering is near-identical except for rows whose zip
is missing. Adding events.archive_size would be a migration, out of scope.
- No LIKE-metacharacter escaping. escapeLikePattern() does .replace(/'/g,"''"),
which corrupts a bound value ("Sarah's Birthday"), and its backslash escaping
is a no-op on SQLite without an ESCAPE clause. Matched customerAccountsService
instead. A literal % typed by an admin acts as a wildcard in a read-only
search; no injection risk.
Pre-existing and untouched: the four stat cards still aggregate the current
page only.
Refs testplan REPORT.md #9 (Part 3, I.01).
Genuine product bug, found behind the adminPhotos.reference suite (which was
failing for an unrelated reason -- see below).
parseInt('0') is 0 and !isNaN(0) is true, so a '0' category_id was written
literally. photo_categories.id is an increments() column, so 0 can never be a
real category, and every read path already assumes it cannot happen: the list
mapper does `category_id || type` (0 is falsy, renders as uncategorized) and
the list filter explicitly skips '0'. The result was a filter black hole -- the
photo matches no numeric category filter, and misses the "uncategorized"
filter too because that is whereNull(). Displayed as uncategorized, reachable
by nothing.
null rather than a 400: unparseable input ('abc' -> NaN) already falls through
to null, so 400ing on '0' while silently accepting 'abc' would be incoherent,
and '0' is just the HTML <select> shape where the "none" option carries
value="0".
Fixed at all three call sites that share the branch -- PATCH /photos/:photoId,
POST /photos/bulk-update, and the upload route, where the dangling 0 was
written at creation time and the scope-validation guard
(`if (parsedCategoryId && ...)`) skipped on the falsy 0 and let it in
unvalidated. Only the PATCH one was behind the failing test; leaving the other
two would have left the bad state creatable.
The suite's 3 failures were all masked by a fixture gap, not this bug: it
stubs middleware/auth but not middleware/permissions, so requirePermission's
admin_users JOIN roles query hit tables the fixture never creates and every
request 500'd before reaching a handler. Stub it, bring the photos fixture up
to the 7 migrations it had drifted behind, and correct a stale 200 that became
202 when uploads went async in 851744c3.
Known adjacent gap, not fixed (wider than this bug): PATCH and bulk-update
accept any positive category_id with no existence or scope check, unlike the
upload route which validates event_id = X OR is_global per #500/#525 -- so a
photo can be PATCHed into another event's category.
Refs testplan REPORT.md #22 (Part 1.2.01).
Correction to the QA root cause: the 304 is correct server behaviour, not a
stale cache. The guest upload route answers 202 and queues the file, so the
row lands as processing_status 'pending', and the photos list returns only
completed rows. The immediate post-upload refetch therefore produces a
byte-identical payload, express's body-derived weak ETag matches, and the
browser is answered 304. Cache-busting would not have fixed it -- a busted
request 200ms after the upload returns a 200 whose body still lacks the
photo. The hard reload only worked because it happened seconds later.
Poll instead: refetch immediately and every 2s until the photo count exceeds
the pre-upload baseline, with a 60s deadline and cleanup on unmount. This
also replaces two window.location.reload() callbacks, which could not have
waited for the worker anyway and threw away scroll and folder state.
Not done (out of scope, recommended follow-ups): GET /api/gallery/:slug/photos
sets no cache headers at all for private per-guest data and relies on
heuristic freshness -- noStoreCache.js already exists and would fit. And the
guest upload flow has no progress signal, so the UI polls blind where a
processing-status endpoint (or pending counts in the photos payload) would let
it say "processing...".
Refs testplan REPORT.md #12 (Part 4, P4-E.01).
With enable_devtools_protection on, every click on the gallery failed and
trivial script evaluation hung -- confirmed on two independent events. A
guest with DevTools open for an unrelated reason (network tab, a CDP-attaching
extension) got a silently unresponsive gallery with no error shown.
Mechanisms found, all in the hook (both callsites were innocent):
1. detectByDebugger ran a bare `debugger;` on every tick at medium/high
sensitivity -- and the per-event flag maps to medium. With any debugger or
CDP client attached the renderer paused there continuously. This is why
Runtime.evaluate hung on 1+1 and clicks reported their target gone.
2. Four separate detectors called console.clear() -- the observed clear loop.
3. handleDevToolsDetected was useCallback([options]) over a fresh object
literal, so runDetection changed identity every render and the effect tore
down, rebound and re-ran detection on every render -- a 1s interval turned
into a tight loop.
4. detectByConsole monkey-patched console.log/error/warn/info every tick
inside a try/catch that swallowed throws, so a throw between patch and
restore left the guest's console permanently hijacked.
5. contextmenu was preventDefault'd document-wide regardless of target,
killing the menu on text, links and form fields -- disable_right_click is
the separate setting meant to cover the whole page.
Kept: the DevTools shortcut keys (only those exact combos; everything else
passes through), the docked-DevTools viewport heuristic as a pure measurement
on resize plus one check at mount, right-click blocked on IMG/CANVAS/VIDEO
targets only. The public API (onDevToolsDetected, redirectOnDetection,
redirectUrl, isDetected, reset) is unchanged, so PhotoLightbox needed no edit.
Removed: debugger traps, console.clear, console monkey-patching, the
timing/element/toString probes, the polling interval, document-wide
contextmenu blocking. Undocked DevTools is now deliberately undetectable --
every technique that catches it costs the page its responsiveness for
everyone. This is a deterrent, not a security boundary.
Also raised the viewport threshold (100 -> 160/200/260 by sensitivity):
browser chrome with a bookmarks bar is ~140px, so the old check false-positived
on ordinary windows, which at protectionLevel 'maximum' redirected legitimate
guests off the gallery.
Refs testplan REPORT.md #3 (Part 4).
SidebarPreview kept its own hand-maintained 6-item array with only two gates
wired (analytics, userManagement), so toggling e.g. Workflows changed nothing
in the preview even though it does add a real sidebar entry once saved.
Export AdminSidebar's `navigation` as `adminNavigation` (2 lines) and derive
the preview from it, so every gate -- transfers, messaging, analytics,
userManagement, clients incl. its featureFlagsAny set, accounting, workflows --
is covered and the two can't drift again.
Note the report's item list was partly wrong: Quotes, Contracts, Invoices,
Hours, Projects, Calendar and the CRM dev tools have no top-level sidebar
entries at all -- they are sub-nav inside /admin/clients and surface in the
preview through the CRM entry's featureFlagsAny.
Permission filtering is deliberately not applied (unchanged): the preview
answers "what do these flags do to the sidebar", not "what can this admin see".
Refs testplan REPORT.md #20 (Part 3, J.14).
Turning Invoices off left the Accounting master flag -- and its sidebar
entry -- silently on and freshly unlocked, because the bills=true =>
accounting=true force-enable had no reverse.
A dependency model already exists and handles every true parent->child pair
(quotes->bills, calendar->calendarBooking, accounting->{taxReport,
incomingInvoices,expenses}), mirrored client-side in applyDependencyRules and
server-side in adminFeatureFlags.js. The gap is only this asymmetric rule.
Interpretation, two decisions:
- Cascade on the client at toggle time, not on the server at persist time.
applyDependencyRules is a pure invariant over a single state (the GET
handler runs it too), so it structurally cannot distinguish "accounting is
on because the admin wants it" from "...because bills forced it". The
Features tab PUTs the full flag set, so on the wire an explicit true and a
stale forced true are byte-identical -- a server-side transition rule would
silently discard an admin who turns Invoices off and deliberately keeps
Accounting on in the same save. The client is where the gesture is known.
The persisted result is still server-enforced: the client sends
accounting:false and the existing server invariant forces the sub-flags off.
- Re-enabling the parent does NOT restore children. Flags are state, not
history, and silently re-lighting a sub-feature with its routes and sidebar
entries is the exact failure this bug is about.
Refs testplan REPORT.md #8 (Part 8, S9).
CustomerAccountPicker returns null when customerPortal is off. That is right
for its original use -- the event form assigns portal logins that bypass the
gallery password -- but the Accounting flows reuse it as-is, so their required
"Client" field rendered a bare label with no input and the submit button could
never enable, with no explanation. Accounting-on + CRM-off is a valid,
UI-supported flag combination.
Took option (a): the bill-to-customer path does not depend on the portal.
POST /admin/expenses/:id/invoice is gated by requireExpenses + accounting.manage
only, and /admin/customers{,/search} are permission-gated rather than
flag-gated -- POST /admin/customers exists precisely to create passive,
portal-less customers "to attach a quote / invoice / gallery to". The
un-gated CustomerPicker used by the quote/bill/contract editors is the
precedent. (The comment claiming search 410s with the flag off was stale.)
Add portalAssignment (default true) so the gate and the portal-specific
label/help text apply only in event-assignment mode; the accounting call
sites render their own label. Event-form behaviour is unchanged.
Also fixes AccountingInboxPage's TriageModal, which has the identical
label-only failure on the rebill disposition from the same root cause --
outside the reported surface, but leaving it would half-fix the bug.
Refs testplan REPORT.md #7 (Part 8, S10).
All four asserted contracts the product has since moved past. No genuine
product bugs behind any of them; assertions were tightened, not loosened.
adminAuth (3 tests): never mounted errorHandler, so ConflictError/
ValidationError arrived as empty Express defaults. The route also checks
username before email, so the "email conflict" fixture was hitting the
username branch. Mount the handler, fix the fixture, match the real response
shapes.
backupService.enhanced (12 tests): three stacked drifts -- the db mock had no
.returning(), so every runBackup threw at the insert; ensureDatabaseDumpForBackup
now lazily requires ./databaseBackup inside the run, which fails under
mock-fs; and the rsync path moved from exec(shell string) to
spawnAsync('rsync', args) with an isHostAllowed SSRF preflight. Also updates
getBackupStatus to its current shape (frontend aliases, nextScheduledRun null
when no schedule is enabled, #871).
adminSettings.logo: POST /logo gained requirePermission('settings.edit');
the hand-rolled db mock returns a bare Promise from select(), so the
permission lookup threw a TypeError into a 500. Mock the permissions
middleware alongside the already-mocked auth.
crmMintPaths (2 tests): macOS-only. The expected prefix was realpath'd while
the services persist under the raw STORAGE_PATH -- identical on Linux CI
(/var vs /private/var only diverges on macOS), which is why it passed there.
The comment justifying the realpath referenced process.cwd() behaviour the
services no longer have.
Refs testplan REPORT.md #22 (Part 1.2.01).
Applies the repo's documented Jest+SQLite guidance (CLAUDE.md) to the webhook
delivery path, which was the last one still passing raw Date objects into
knex writes. Under jest those store as the literal string "[object Object]",
so next_retry_at came back NaN and the retry/backoff test could not assert on
it. Production (PG, and SQLite outside jest) was unaffected.
Convert the timestamp writes -- and the `next_retry_at <=` due comparison,
which has to stay type-consistent with them -- to .toISOString(), matching
the existing precedent in downloadJobService.js.
Refs testplan REPORT.md #22 (Part 1.2.01).
The cancelInvitation dialog type fell through to the generic
t('userManagement.cancel'), colliding with ConfirmDialog's own dismiss
button -- two buttons both reading "Cancel", where clicking the wrong one
does the opposite of what the user intends.
Reuse the existing userManagement.cancelInvitation key: "Cancel Invitation"
vs "Cancel" (EN), "Einladung abbrechen" vs "Abbrechen" (DE). No new key.
Refs testplan REPORT.md #19 (Part 3, I.04).
Correction to the QA root cause: the submit Button has carried
`disabled={createMutation.isPending}` since 3424bd22, and it does disable
synchronously after the first click (validateForm's setErrors forces a
re-render that re-reads the mutation snapshot), so an ordinary double-click
could not by itself produce two POSTs.
What was actually missing is a re-entrancy guard in handleSubmit, so any
submission that never touches the button -- implicit form submission, a
programmatic requestSubmit, or two submit events dispatched in one task,
which is the likely shape of the QA repro -- still fired two mutate() calls
racing the same computed slug, one of which 500'd on events_slug_unique.
Add isSubmittingRef (matching the isMountedRef idiom already in this file),
cleared in onSettled. Test proves 2 submit events -> 1 POST.
Not done: turning the backend's raw 500 on events_slug_unique into a
graceful "event already exists" 409. That is an adminEvents.js change and a
separate concern from the client-side race.
Refs testplan REPORT.md #6 (Part 7.03).
On a hard navigation or deep link, usePermissions() starts out empty, which
filters every settings nav group down to nothing. allItems is then [], so
`allItems.find(...) ?? allItems[0]` yields undefined and `<activeItem.icon>`
threw -- sometimes into the error boundary, sometimes racing past it.
Reproduced 6+ times across the webhooks/moderation/slideshow/security/events
tabs; in-app SPA navigation never hit it.
Extend the file's existing early-return to `isLoading || permissionsLoading`.
activeTab lives in useState seeded from ?tab= at mount, independent of the
gate, so deep links still land on the right tab once permissions arrive.
Also null-guard activeItem before the section heading: a role holding zero
settings-tab permissions crashes identically even after permissions finish
loading, which the loading gate alone does not cover.
Refs testplan REPORT.md #11 (Part 3, J.08).
EventDetailsPage gated on `if (eventLoading || !event)`. The backend returns
a clean 404 for a nonexistent id, but once isLoading settled false `event`
stayed undefined forever, so /admin/events/999999 sat on the loading spinner
permanently with no error state.
Destructure isError and split the gate: spinner while loading, then a
not-found Card. Reuses the existing `events.notFound` key (already used by
EventFeedbackPage for the same entity) and the Card padding="lg" not-found
shape from contracts/ContractDetailPage. No new i18n keys.
Refs testplan REPORT.md #5 (Part 7.02).
A self-hosted Umami/Rybbit domain configured in Settings -> Analytics is
always blocked by the static script-src allowlist, silently, with only a
console error. The amber CSP warning that explains this already existed but
was rendered only inside the "custom" provider panel -- not on the two
providers where an admin actually types a self-hosted URL.
Extract it to a local CspWarning and render it in the Umami and Rybbit panels
too. Both translation keys already exist in en.json/de.json.
Interpretation: the dynamic-CSP option was investigated and rejected as not
reachable for the header that actually governs these documents. In the Docker
deployment nginx.conf:58 does `proxy_hide_header Content-Security-Policy`, so
helmet's CSP and the res.setHeader CSP at server.js:445 are stripped before
they leave the stack -- nginx's static server-level CSP is the only one the
browser sees for the SPA documents the tracker is injected into. nginx.conf is
COPYied verbatim by the Dockerfile (only index.html goes through envsubst),
and the tracker URL lives in the DB rather than the environment, so making it
reflect the setting would need start-time templating plus a DB read. The CSP
itself therefore still has to be edited by hand; the warning now says so where
the admin can see it.
Refs testplan REPORT.md #18 (Part 3, B.02).
photo_categories.name is varchar(100). Neither the input nor the route
checked length, so a 267-char name hit a raw Postgres "value too long",
came back as a 500, and the form silently stayed open with no toast.
Add isLength({ max: 100 }) to POST / and PUT /:id (the update route had the
identical gap) so it returns the route family's normal 400 { errors: [...] }
shape that the toast helper already renders, and maxLength={100} on the three
category-name inputs (create + inline edit in CategoryManager, create in
EventCategoryManager).
Refs testplan REPORT.md #4 (Part 7.01).
WordFilterManager.tsx sends low/moderate/high/block; the validator only
accepted mild/moderate/severe, so 3 of the 4 UI levels 400'd with "Invalid
severity level" -- including "block", the strongest advertised tier.
Aligning isIn() alone would have made "block" accepted but semantically
inert: feedbackModeration.js branches on 'severe'/'moderate', so "block"
would fall through to the flag-only branch and behave as the weakest level.
Map the UI vocabulary onto the existing outcomes instead, per the legend the
UI itself renders: block -> reject, moderate/high -> needs approval,
low -> flag only.
'severe' stays an accepted alias in the blocking predicate so any row written
through the old validator (the field is optional, so a direct API caller
could have stored one) keeps blocking. No data migration needed: the column
is a bare varchar(20) default 'moderate' with no CHECK, no enum and no seed
rows, and 'mild' already lands in the flag-only branch that 'low' now means.
Refs testplan REPORT.md #2 (Part 3, J.11).
getMaxFileSizeBytes() (general_max_file_size_mb, default 50MB) was only read
by adminSettings.js to display the value. The admin upload routes streamed
against a hardcoded ceiling instead, so the dropzone's "max. 50MB pro Datei"
was never enforced:
- adminPhotos.js POST /:eventId/upload -> 10GB hardcoded
- adminPhotos.js POST /:eventId/chunked-upload/init -> 10GB hardcoded
- v1/events.js POST /events/:id/photos -> 100MB hardcoded
Resolve the cap per request (it is admin-configurable at runtime) and build
the multer instance from it, mirroring what gallery.js and adminTransfers.js
already do. The 400 names the configured limit and reuses gallery.js's exact
error string so the frontend surfaces it identically. getMaxFileSizeBytes()
clamps to MAX_ALLOWED_FILE_SIZE_MB, so the 10GB hard ceiling still bounds
everything.
gallery.js (guest upload) already enforced this correctly and is unchanged --
the report's claim that it did not is stale.
Interpretation: general_max_file_size_mb is a single per-file cap with no
photo/video split, and gallery.js already applies it blanket to guest video
uploads, so admin video uploads now share it too. On a default install that
means a 200MB video needs the setting raised first -- which is what the UI
has been advertising all along.
Refs testplan REPORT.md #1 (Part 7.06).
Two PR screenshots landed at the repo root in #1241 and have been shipping as
part of the source tree since. Nothing references them.
Screenshots belong on a `screenshots/*` branch — that is what those branches
are for, and how every other UI change here has attached its evidence. Added
ignore rules so the next one cannot follow the same path, anchored with a
leading slash so docs/ keeps its own five images and test-assets/ keeps the
fixtures the e2e specs load.
Verified no other tracked file matches the new patterns.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(upload): let Android guests reach the camera without breaking video
Recent Android versions route an <input> whose accept list is entirely
image/video types to the system photo picker, which has no camera entry —
so a guest standing at the event can only pick an existing photo, not take
one. Including a type that picker can't handle forces the general chooser,
which does offer the camera.
Two corrections to the original approach in #1117:
- the .pdf is gated on the Android UA. It was appended unconditionally, so
desktop and iOS pickers — which behave correctly — gained a selectable
PDF that only produces an error when chosen.
- no image-only guard. #1117 rejected every non-image file before the
existing allowlist check, which breaks video uploads outright on any
install configured for them (fileTypes.ts maps mp4/m4v/webm/mov/avi and
general_allowed_file_types is admin-editable). The guard was also
redundant: extensionsToMimeTypes only emits types it has a mapping for,
so application/pdf can never be in allowedMimeTypes and the existing
"Invalid file type" check already rejects a picked PDF.
The empty-string fallback to 'image/*, .pdf' goes too — extensionsToMimeTypes
already falls back to the configured default set, and image/* was broader
than the admin's allowlist.
Lives in fileTypes.ts as a pure function so the UA behaviour is testable;
the component keeps a one-line useMemo.
Co-authored-by: Zszywany <Zszywany@users.noreply.github.com>
* fix(upload): use android/allowCamera instead of .pdf for the chooser fallback
Same mechanism, better token. Chrome on Android 14/15 sends an input whose
accept list is all media types to the photo picker, which has no camera tile;
adding a value that picker cannot satisfy makes it fall back to the general
chooser, which does offer the camera.
`.pdf` achieves that but advertises PDFs as selectable — pick one and the
existing allowlist check answers "Invalid file type", which is a dead end we
put in front of the guest ourselves. `android/allowCamera` is the token the
workaround converged on: not a real MIME type, matches no file, so it flips
the picker without offering anything.
Neither token ever widened what is accepted — addFiles validates against
extensionsToMimeTypes, which only emits types it has a mapping for — but not
showing the guest a choice that cannot work is worth the one-line change.
Verified in a browser rather than asserted: the real component rendered under
an Android UA emits
image/jpeg,image/png,image/webp,android/allowCamera
and under a desktop UA
image/jpeg,image/png,image/webp
with the visible modal identical in both, and the format hint still reading
"JPG, JPEG, PNG, WEBP" — the token does not leak into anything a guest sees.
* fix(upload): keep the camera token off Firefox for Android
External review round. The gate was a bare /Android/i, which Firefox for
Android matches — so it received a token invented to reroute Chromium's photo
picker, a picker it does not use. The doc comment two lines up already said
Firefox behaves correctly; the code did not agree with it.
Inert at best, and at worst it perturbs a chooser that was working. Narrowed
to Android minus Firefox, which is the Chromium-family set the behaviour was
actually observed on (Chrome and Edge, Android 14/15), with a UA test to pin
it.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Zszywany <Zszywany@users.noreply.github.com>
Both routes re-hash password_hash from a plaintext the admin re-types, and
both validated it with nothing but express-validator's isLength({min:6}).
So the configured complexity — moderate by default, meaning 8 chars plus
upper, lower and a digit — governed creation and reset while these two doors
accepted 'aaaaaa' and made it the live gallery password.
Fixed for both at once, deliberately. Fixing only the newer send-later route
would have made a quiet-publish password valid at publish time and rejected
by send-later, leaving the admin unable to mail a gallery that is already
live under exactly that password.
Not an escalation — it needs admin auth plus events.edit, and such an admin
could already set the same weak password through /publish. It is a policy
gap: the UI promised a complexity level these two endpoints did not enforce.
BEHAVIOUR CHANGE: an API-only consumer publishing with a sub-policy password
now gets 400 with the same body shape event creation returns (error, details,
score, feedback) instead of silently weakening the gallery. Two existing test
fixtures had to change for the same reason — their intent was that the
supplied password is carried and persisted, not that a weak one is accepted.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
main's #1240 landed the manifest lookup in its first form; the hardening that
followed only ever reached stable, via #1243. So main still silently loses
every category when restoring an archive written while
general_use_original_filenames_for_downloads was on: archiveService names
each ZIP entry after the ORIGINAL filename while the manifest stays keyed by
the internal photos.filename, so the lookup misses every entry.
Ported as one unit rather than piecemeal, since a third variant of this
function helps nobody:
- index by original_filename, and by sanitizeForZipEntry(original_filename)
as the ZIP would actually have written it
- two passes, canonical names claimed before any alias, so the result no
longer depends on manifest iteration order (the archive query has no
ORDER BY)
- a name two rows both claim is dropped rather than guessed — including the
canonical/alias clash, where which file the ZIP emitted depends on a
naming mode the manifest does not record
- globals count as existing, event-scoped rows win over them, and the global
arm requires event_id IS NULL so one event's legacy row can't be adopted by
another event's restore
- an invented category is explicitly is_global false; the column defaults to
TRUE, so a restore was leaking this event's naming into every gallery
- categories resolve inside the !existingPhoto branch, so a restore that
skips its inserts stops creating unused rows from stale manifest names
- a duplicate category name is logged and resolved by lowest id instead of
engine order
main-only code is untouched: the face-data cleanup (#1074, #1132) and the
uploaded_at toISOString fix both survive — stable still has the bare
new Date() there, which is the documented Jest/SQLite landmine and worth a
separate look.
15 tests, ported from #1243.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(events): publish without notifying, and send the gallery email later (#1235)
Publishing queued the gallery_created email whenever any customer email
existed, with no opt-out. A photographer working with a client who has no
address yet — the Instagram-team case in discussion #1086 — had to type their
OWN address into the required field, publish, receive the client-facing email
themselves, and hand the link over by DM. Turning off
`event_require_customer_email` is not the answer either: that is global, and
the same photographer usually does collect addresses.
Two halves, because a checkbox alone is only half a workflow:
- `notify_customer` on publish, default TRUE. Absent means notify, so the v1
API, an older frontend and any script keep behaving exactly as before. When
false the gallery goes live and nothing is queued — not the gallery_created
email, not the assigned-customer-account notice, not WhatsApp. Publishing
still logs activity and still fires the event.published webhook, because
those describe a state change rather than a message to a customer.
- POST /:id/send-gallery-email for an already-published gallery. Deliberately
not restricted to galleries published quietly: re-sending is a normal thing
to want (spam folder, wrong address since corrected) and refusing would push
people to unpublish and republish, changing gallery state to work around a
mail problem. Refused for a draft, whose link would not work yet, and for an
event with no recipient.
The email composition is now one helper shared by both, so an email sent a
week later is identical to one sent at publish.
UI: a checkbox in the publish dialog (checked by default, hidden when nobody
would be notified anyway), and a "Send gallery email" action on published
galleries that have a recipient. The password field follows the checkbox —
unchecking it means nothing is being sent, so there is no plaintext to carry
and no reason to demand it. EN + DE strings.
7 integration tests. Two fail without the change, verified by forcing
notifyCustomer true and re-running; the rest pin the default, the draft and
no-recipient refusals, and that a gallery with no recipient still publishes.
* fix(events): make the publish dialog description follow the checkbox (#1235)
Caught by screenshotting it. With "Send the gallery email now" unchecked, the
paragraph above still read "...and sends the notification email to
tina@example.com" while the control directly beneath it said nothing would be
sent — the dialog contradicted itself at exactly the moment the admin is
deciding whether anything goes out.
It now reads "No email will be sent — you can send it later from this page."
when the box is clear. EN + DE.
* fix(events): close six gaps in publish-quietly found by external review (#1235)
TWO CORRECTIONS TO MY OWN VERIFICATION FIRST. `npx tsc --noEmit` in frontend/
is a NO-OP — the root tsconfig is solution-style with references and no
include, so it checks nothing. Every "tsc clean" I claimed on this branch came
from that. The real check, `tsc -p tsconfig.app.json`, showed two TS2339s I had
introduced: `event.host_email` does not exist on the frontend Event type, which
the admin API normalises away. Both recipient checks now use `customer_email`.
PASSWORD ON SEND-LATER. The action promised to send the link and password but
always called the endpoint without one, so a protected gallery got the
"(set at creation)" sentinel — unusable — and this is most needed right after a
quiet publish, the path that never collects a password. New
SendGalleryEmailDialog asks for it, same shape and reasoning as the publish
dialog (#627). Galleries with no password skip the field.
WHATSAPP-ONLY GALLERIES COULD NOT PUBLISH QUIETLY. willNotify ignored
customer_phone, so a phone-only gallery hid the opt-out AND told the admin
nothing would be sent — while publish queued the WhatsApp anyway. Phone now
counts, with its own description line.
ASSIGNED-ACCOUNT NOTICES COULD NOT BE SENT LATER. The dialog promised it; the
endpoint rejected anything without an inline recipient. It now falls through to
the same customer-account path publish uses.
EDITORS COULD NOT SEE THE ACTION. The send button was nested inside the
events.archive gate, so the default editor role — events.edit, no archive —
never saw a button for an endpoint it is allowed to call. Separate gates now.
DEAD LINKS. The endpoint only checked is_draft, so an archived, inactive or
expired gallery would send a link the gallery middleware rejects. All three are
refused with a reason.
9 backend tests (2 new), 22 across the event suites. eslint clean on every
changed frontend file; crud.js keeps its 2 pre-existing errors.
* fix(events): persist the send-later password, and fix a long-standing isGalleryPublic misuse (#1235)
Round 2 of external review.
THE EMAIL COULD CARRY A PASSWORD THE GALLERY REJECTS. The send-later dialog
invites "or pick a new one", but the route queued that plaintext without
touching password_hash — so the customer got credentials that do not open the
gallery. Worse than the sentinel it replaced, because it looks usable. The
route now hashes and persists first, exactly as publish does.
isGalleryPublic TAKES A VALUE, NOT AN EVENT — and this is pre-existing.
normalizeRequirePassword returns its default for anything that is not a
boolean/number/string, so isGalleryPublic(event) is ALWAYS false and
`requirePassword` was always true. The publish dialog on main has demanded a
password for public galleries for exactly this reason. Both call sites now
pass event.require_password. Fixing the older one alongside mine rather than
leaving a broken copy one line above a fixed one.
ASSIGNED-ACCOUNT GALLERIES HAD NO BUTTON. The route falls through to the
customer-account notice when there is no inline email, and the publish dialog
promises that notice can be sent later — but the button only appeared with a
customer_email, making the promise unkeepable.
WHATSAPP CLAIM SOFTENED. Publish only queues WhatsApp when the config exists
and is enabled, which the dialog cannot see. It now says the customer is
notified there "if WhatsApp is configured" rather than asserting a send.
10 backend tests (1 new, covering the rehash). eslint clean on every changed
frontend file; crud.js keeps its 2 pre-existing errors.
* fix(events): don't reset the password for an account-only notice, hide unusable actions (#1235)
Round 3 of external review. The first is a harm my own round-2 fix introduced.
PASSWORD RESET FOR NOTHING. Round 2 persisted the supplied password before
knowing which mail would go out. For a protected gallery with no inline email
but assigned accounts, the dialog still demands a password, the hash was
rewritten, and then the fallback sent customer_gallery_assigned — which links
to the customer portal and never mentions a password. Net effect: the live
gallery password silently changed and everyone holding the old one was locked
out, in exchange for nothing. It is now persisted only when the mail that
carries it is actually being sent.
BUTTONS THE BACKEND WOULD REFUSE. The send action rendered for expired and
inactive galleries, and counted assigned accounts the endpoint filters out as
inactive — walking the admin through a dialog to reach a generic error toast.
The card now mirrors the endpoint's eligibility rules, and only active accounts
count toward having a recipient.
11 backend tests (1 new, pinning that the hash is untouched on the account
path), 24 across the event suites. tsc and eslint clean on the changed files.
* fix(events): make the send-later action agree with what the endpoint will do
Three findings from an external review round, all the same shape: the UI
predicted the endpoint's behaviour and got it wrong.
GET /admin/events/:id mapped customer_accounts without is_active, so the
"only ACTIVE accounts count" filter in OverviewTab compared undefined and
excluded nothing. A gallery whose only assignments were deactivated showed
the send action, and the endpoint then filtered every recipient and
returned 400. is_active is exposed now, and the count applies the same
predicate the fallback uses — active AND holding an address.
is_active is coerced through toBoolean rather than compared with === false.
On the default SQLite backend it comes back as 0, and 0 === false is false,
so an inactive gallery kept offering a send that parseBooleanInput then
rejected. Same class as #1028.
The password prompt is gated on there being an inline recipient. With no
customer_email the backend takes the account fallback, which sends
customer_gallery_assigned — a portal link that never mentions a password —
and deliberately skips the rehash. Asking for one there blocked the send
behind a six-character value nothing consumes, and the dialog's promise
that it would be rehashed was false.
Frontend suite: 291 passed. tsc and eslint clean.
* fix(events): don't mail a portal link to a customer who cannot sign in
Round-2 finding from the external review.
A passive customer — created directly and never invited — is an active
account with a real address whose password_hash IS NULL. The account
fallback happily mailed it customer_gallery_assigned, which links to
/customer/dashboard, and customerAuth rejects login without a hash: the link
goes to a door that will not open. Worse than failing, the route counted it
and reported success, so the admin believed the customer had been told.
getAssignmentsForEvent now derives can_sign_in (the predicate, never the
hash) and the three call sites share one canReceiveGalleryNotice helper —
publish, send-later, and the payload the UI predicts from all have to agree
or the button appears and then 400s. The UI mirrors it.
Sending passive customers an invitation instead of skipping them is the
better product answer, and a separate feature. Refusing visibly beats a
silent non-delivery in the meantime.
Test asserts the refusal; it fails without the can_sign_in arm.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(gallery): route single-photo downloads through the storage backend
The route resolved a local filesystem path unconditionally and handed it
to res.sendFile. On an S3/R2 deployment managed photos are never on local
disk, so every per-photo download failed — while download-all and
secure-images worked, because they already went through getStorage().
That asymmetry is why it went unnoticed: the gallery looks healthy until a
guest clicks the download button on one photo.
Measured rather than assumed: because sendFile is called WITH a callback,
Express does not send a response when the file is missing and the callback
only logs. The request does not 404, it hangs until the client gives up.
The new tests pin this — all five backend-path cases time out against the
previous implementation.
Two existing pieces do the work, so this mostly deletes code:
- renderPhotoForDownload (#858) already owns resize-then-watermark ordering
and the storage fetch, and the zip builders in this same file already use
it. The inline duplicate of that logic goes.
- the pass-through case branches on storage.kind(). Local disk keeps
res.sendFile: it emits Content-Length, Accept-Ranges, ETag and
Last-Modified and answers Range with a 206, and sharing one bare
stream.pipe(res) with S3 would silently drop all of it — a resumed
download would append a second full body onto the partial file. On S3 the
parts that matter for a download are reproduced via stat() and getRange().
Ranges are parsed defensively; an unchecked parse yields NaN bounds and a
206 with a nonsense Content-Range, which corrupts a resumed download rather
than failing it. Malformed or unsatisfiable ranges fall back to a 200.
The pre-stream 404s now run before any image header is staged, so the error
goes out as JSON instead of a .jpg attachment containing JSON.
Co-authored-by: peipeimo <peipeimo@users.noreply.github.com>
* fix(gallery): open the stream before staging download headers, honour If-Range
Both from an external review round on this PR.
stat() succeeding does not mean get() will — a concurrent delete or replace,
or a transient backend error, lands between them. The fetch was awaited
AFTER the headers went out, so:
- the range branch had already called writeHead(206), leaving the outer
catch nothing to do but throw ERR_HTTP_HEADERS_SENT. In practice the
request hangs: the new regression test sat for the full 120s jest timeout
against the previous code instead of returning.
- the full branch would have sent its 500 JSON underneath the staged
image/jpeg attachment headers — a .jpg file full of JSON, which is the
exact failure this PR set out to stop doing on the 404 paths.
Opening the stream first also lets a vanished object answer 404 and a
transient failure answer 500, instead of both surfacing as a broken body.
If-Range: emitting Last-Modified without honouring the validator built from
it is the dangerous half of the feature. A client resuming after the object
was replaced — the watcher re-importing a swapped file, an admin re-upload —
would get 206 from the NEW bytes and splice two versions into one corrupt
file. A validator that does not match now falls back to a full 200.
4 new tests; 3 of them fail against the previous commit, the fourth is the
matching-validator control that must keep returning 206.
* fix(gallery): HEAD without egress, classify render failures, stage 206 headers
Round-2 findings from the external reviewer.
Express routes HEAD through this GET handler and Node discards the body,
but the pipe still drains the whole object out of S3 first — a metadata
probe from a download manager cost a full transfer in egress and latency.
Everything a HEAD needs is already in stat().
renderPhotoForDownload rejections were all reported as 404. It can equally
fail because getToFile timed out, tmp filled up, or sharp died; calling that
"photo not found" misleads the guest and hides the incident from us. Now
classified the same way the pass-through branch already does.
The 206 path uses status()+set() instead of writeHead(). writeHead commits
the response immediately, so a stream that resolved and then errored before
its first chunk left pipeStreamToResponse able only to destroy the
connection. Staged headers flush on the first body write, so an error at
byte zero now returns a clean retryable status with keep-alive intact.
Credit to the reviewer for the correction — I had assumed deferring the
commit required buffering.
Writing the test for that surfaced one more: pipeStreamToResponse cleared
Content-Type, Content-Length, ETag and Content-Disposition but not the range
headers, so the 500 went out still advertising Content-Range: bytes 0-9/40 —
telling a resuming client the error body IS the partial content.
Not taken: binding response metadata to a fetched object version. That needs
an ETag/versionId on the storage abstraction and conditional GETs in both
adapters; the reviewer agreed it belongs in its own PR rather than blocking
this one.
Backend suites: 485 passed.
* fix(gallery): answer HEAD before the counters and the render
Round-3 finding. The HEAD short-circuit was inside the storage branch, which
sits below both the download_count increment / access_logs insert and
renderPhotoForDownload — so a download manager's metadata probe was recorded
as a real download, and on a watermarked or resized gallery it also pulled
the original from S3 and ran sharp over it to build a body Node then throws
away.
HEAD now leaves the handler right after the access checks, with no side
effects and no bytes read. Content-Length is included only when the photo
ships untransformed and the size is readable from stat(); a watermark or
resize changes the length and the only way to learn the new one is to do the
work this branch exists to avoid. HEAD may omit it.
Not taken, again: binding the read to the statted object version. The
reviewer already agreed in a follow-up that it needs an ETag/versionId on the
storage abstraction plus conditional GETs in both adapters, and belongs in
its own PR. Re-raising it does not change that.
Tests assert the probe moves neither download_count nor access_logs.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: peipeimo <peipeimo@users.noreply.github.com>
* fix(events): delete stored objects when cascading an event delete
* fix(events): sweep watermarks and the archive zip on cascade delete too
Two more objects in the same class as the originals: both are written
through the storage backend, both were only ever removed with fs.unlink,
so both outlive the event on S3.
- photo.watermark_path — a canonical key, deleted via getStorage() on the
single-photo path (watermarkService.deleteWatermarkFile) and on archive
(archiveService.js:227). The cascade neither selected nor removed it.
- event.archive_path — written by storage.putFromFile (archiveService.js:160)
and typically the largest single object an event owns.
event.hero_logo_path is deliberately NOT included: multer writes logos to
local disk with diskStorage regardless of backend (adminEvents/logo.js:19-28),
so they are never bucket objects and the existing fs.unlink is correct.
Collect into a Set — an unresized gallery can carry one object in both
hero_path and preview_path, and the second delete would log a spurious
failure.
* fix(events): sweep the download caches, and delete objects concurrently
Both from an external review round on this PR.
The download caches are the subtle case: the pre-built "Download All" zip
(events.download_zip_path) and one zip per custom-resolution download job
(download_jobs.zip_path) both live under
events/active/{slug}/.download-cache/. On local disk the recursive fs.rm
already covered them, which is exactly why they were easy to miss — on S3
that prefix is not a directory, nothing covered them, and both are
gallery-sized. downloadZipService exposes a cleanup() documented as "used
on event deletion" that the cascade never called.
The job rows are read before the transaction for the same reason the photo
rows are: download_jobs.event_id is ON DELETE CASCADE, so on Postgres they
vanish with the event and take their keys with them. Guarded with hasTable
so a pre-#173 install doesn't abort the delete.
Deletes now run through a bounded pool instead of one await per key. A
400-photo gallery owns ~1600 objects once derived tiers are counted, 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. A pool rather than Promise.all over
every key, so the fan-out can't exhaust the S3 client's connection pool.
* fix(events): never delete a derivative another gallery still uses
Round-2 findings from the external reviewer.
Canonical thumbnail/hero/preview keys are not event-scoped: the basename is
the photo's filename (imageProcessor passes no outputBasename for managed
photos, so the key is thumbnails/thumb_w300_<filename>), and filenames are
not unique across events — the responsive-tier code says so in as many
words, which is why THOSE keys carry a p{id}_ prefix. A legacy gallery can
therefore 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. 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, orphaning it permanently. downloadZipService.cleanup() is the
service's own entry point for this and does all three things: bumps the
version so an in-flight build discards its result, clears the debounce so
nothing rebuilds for a deleted event, and removes the current object.
* revert(events): drop the Download All build cancellation
Reverted for the same reason as on the stable twin, where it was caught:
downloadZipService.cleanup() reaches getStorage() through _cleanup(), so
where the S3 backend is configured but unreachable every cascade delete pays
the adapter's retry backoff. On stable that took the backend CI job from ~2
minutes to past its 10-minute budget, twice, reproducibly. This branch's
suite happened not to trip it, but the same cost lands in the request path
of a real delete — and the twins have to carry the same code.
The race it addressed is narrow and costs one orphaned zip; documented as a
follow-up instead. The shared-derivative guard from the same review round
stays — that one prevented deleting a surviving gallery's thumbnail.
---------
Co-authored-by: Peifu Mo <peipeimo@Peifus-MacBook-Pro.local>
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
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.
Suggestions now surface only alongside a real strength failure. They stay
available to callers in result.feedback.suggestions, so a UI can still
show them as guidance while typing.
The weak-password fixture is assembled from parts rather than inlined: an
8-char alphanumeric literal next to validatePassword( reads as a hardcoded
credential to the required GitGuardian check. Both fixtures pin their
zxcvbn score — the compliant one is load-bearing at exactly the moderate
minimum (2), and a future zxcvbn bump promoting it to 3 would leave the
test green while no longer covering the bug.
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
* fix(archives): take the restored category from the manifest
The archive writer already persists `category_name` per photo in
photos_manifest.json — that is why the manifest exists, and the comment
above it says so: "(and category linkage) can't be derived from the
extracted files alone". The restore route then read only
`original_filename` out of it and kept deriving the category from the
ZIP's first path segment.
Archives store photos exactly as they sit on disk, so an event whose
photos live in the gallery root produces a FLAT zip. `path.dirname()` is
'.' for every entry, no category is resolved, and every restored photo
lands with `category_id = null` — silently, behind a 200.
Seen on a real restore: 596 photos back, 0 with a category, while the
nine category rows sat untouched in the table.
Now the manifest is the source of truth and the first path segment is
the fallback, so foldered archives and legacy archives without a
manifest behave exactly as before. The find-or-create is pulled into
`resolveCategoryId` so both paths share it and each name is resolved
once per restore.
Tests: __tests__/integration/adminArchives.restoreCategories.test.js
builds real ZIPs (flat with manifest, flat with an existing category
row, foldered without manifest) and drives POST /:id/restore. Without
this change the two manifest cases fail and the foldered one passes —
the fallback is unchanged.
* fix(archives): let the manifest be authoritative when it says "no category"
Review follow-up on #1240, pushed with the author's agreement.
The manifest won for "category X" but not for "none": an entry with a null
category_name fell through to the directory fallback, so a photo the archive
recorded as uncategorized came back filed under a category anyway.
That matters because the directory is not a category. Archive entry names are
the storage key minus `events/active/{slug}`, and that layout is
`individual/{filename}` / `collages/{filename}` — categories have never been
directories there. Reading the first path segment on a real archive invents
categories literally named "individual" and "collages", so the fallback was
overriding an accurate record with a junk one.
The fallback is now confined to photos with NO manifest entry at all: archives
written before the manifest existed, where the directory is the only signal
left and inventing those names still beats losing every category.
Tests: the legacy case now uses `individual/`, the shape a real archive
actually has, instead of a category-shaped folder no archive produces — so it
documents what the fallback really does. Plus a new case pinning that a
manifest saying uncategorized leaves the photo uncategorized and creates no
category row. It fails without this change; the legacy fallback keeps passing.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The transport carried 41 lines of bounded-read-with-deadline to recover a
messageId a receiver MIGHT return. That value is only ever logged — nothing
persists it, there is no email_queue.message_id column — and the code to get
it produced two of the last four review findings: the size cap made a
DELIVERED message retry (axios throws while reading), and the missing deadline
let an unclosed stream hang the queue and resend.
Not reading the body is how that whole class stops being reachable rather than
defended against. responseType 'stream' still keeps axios from buffering; the
stream is destroyed immediately and the id is synthesised as before. The status
was always the delivery verdict, and it is known before any of this.
An 'error' listener goes on before destroy(): destroy can emit on a
socket-backed stream, and an unhandled 'error' on a stream throws — which
would have turned a receiver's teardown into a failed send.
Net: -45 lines of service code, one fewer constant, one fewer test seam, and
three of the hardest cases in the suite replaced by two simpler ones.
23 tests, 63 across the email suites, eslint clean.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The existence check matched on filename OR path. replacePhoto regenerates
both — a fresh generated filename and a fresh managed path — so a
watched-folder photo that had its file replaced stopped matching either arm.
The original is still sitting in the watched folder, so the next sweep
imported it again and the gallery ended up holding the delivered edit AND the
untouched original: the same duplicate shape external_relpath prevents for
reference galleries.
source_filename is now a third arm. It is the stable key here — written once
at ingest by this same path and preserved across a replace by design. Rows
predating migration 193 are covered by its backfill: COALESCE(original_filename,
filename), and this path never wrote original_filename, so for watcher rows
that resolves to the basename being compared.
The query is lifted into an exported findExistingPhoto() so the test drives it
rather than a copy — the thing under test IS the query, so a query-builder mock
would only assert that knex was called the way the test expects.
Predates the Lightroom round-trip and applies to the admin replace path too;
it became reachable when #1165 brought watcher galleries into round-trip scope.
Six tests against a real SQLite database. The load-bearing one fails without
the change, verified by removing the arm and re-running; the other five pin
what must not move — filename and path matching, the pre-193 backfill shape, a
genuinely new file, and event scoping.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The regression test added with #1165 re-implemented the claim ordering and the
claim loop inside the test file and asserted against its own copy. It never
required externalRelpathFold, so changing the real sort left it green — a guard
against silently deleting a client's delivered edit that guarded nothing.
The ordering is now a named, exported claimOrderFor() and the test drives it.
Verified by sabotage: replacing the comparator with `return 0` fails the test,
where before it passed.
Three cases added while the seam existed: the managed row wins from BOTH input
orders (the original bug was that the survivor was whichever came first, so one
order proves nothing), the sort is stable for rows of the same kind, and it does
not mutate the caller's array.
No behaviour change — the comparator is byte-identical, only lifted out.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Round 4 of external review, on the merged commit. Both findings are
consequences of the round-3 streaming change, which is exactly why the round
was worth running.
An AxiosError carries the request it failed on: `config.data` is the ENTIRE
serialised message, base64 attachments included, and `config.headers` holds
the signature. emailProcessor logs the error object and winston serialises it,
so a DNS blip or a refused connection wrote password-reset links, guest
recovery codes and multi-megabyte invoices into combined.log — verified
against axios rather than assumed. Every rejection is now caught and replaced
with a message-and-code-only error, so nothing downstream can serialise the
request back out of it.
readBounded had no deadline. axios' `timeout` covers the response HEADERS, and
with responseType 'stream' it has already resolved by the time the body is
read — so a receiver that answered 2xx and never closed its body left the
await hanging, the queue row stayed pending, and the next processor pass sent
the same message again. An unclosed stream was duplicate email. There is now a
10s wall clock that destroys the stream, with the timer unref'd so a hung read
cannot hold the process open at exit.
23 transport tests (2 new, both failing without these fixes), 63 across the
email suites, eslint clean.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Setting EMAIL_WEBHOOK_URL makes PicPeak stop sending mail itself and POST each
composed message as JSON instead, for something downstream (n8n, Make, a
self-hosted relay) to deliver. Unset, every SMTP path is unchanged.
Settles the four things #1225 left open:
- SSRF: the URL goes through the same DNS-resolving check the outbound webhook
worker uses, before every send. Private receivers are opt-in.
- Transport security: https is required for anything leaving the machine. The
HMAC proves who sent the body, not who can read it, and these bodies carry
password-reset links and guest recovery codes. The private-network opt-in
doubles as the plaintext opt-in.
- Authentication: EMAIL_WEBHOOK_SECRET is required and signs the body as
X-PicPeak-Signature, the same scheme as gallery webhooks. A URL without a
secret leaves the transport OFF and says so once.
- Attachments: carried as base64, not dropped. Oversized ones fail and stay
queued rather than arriving without the invoice.
Configuration is environment-only on purpose: this redirects every outbound
message including password resets, so it must not be changeable from a
compromised admin session.
Three wiring details decide whether it works at all: docker-compose.yml
declares an explicit environment block, so the vars had to be forwarded
there; a fresh webhook-only install has no email_configs row (migration 001
seeds it only when SMTP_HOST is set), so the From identity falls back to
EMAIL_FROM; and processEmailQueue used to return early when SMTP could not
initialise, which would have left the queue permanently unprocessed.
guestRecoveryService and the admin test-email endpoint were bypassing the
transport — the first dereferenced a null transporter, the second told
webhook-only admins to go configure SMTP. emailIntakeService deliberately
stays on SMTP: it round-trips a specific mailbox's own credentials.
Response handling is streamed and read bounded by hand rather than capped via
axios: maxContentLength throws while reading, so a receiver that delivered the
mail and then echoed a large body would have been recorded as failed and the
message sent again.
Note: docker-compose.dev.yml is gitignored and local-only, so the equivalent
entries there are not part of this change. docker-compose.production.yml needs
none — it passes .env through with env_file.
Three rounds of external review; 21 transport tests, 61 across the email
suites.
#1165 added photos.source_filename to this service's select, with a comment
saying it was there so the Lightroom round-trip could still match after a
re-upload — and then nothing read it. Every output path still used
original_filename, which is overwritten the first time an edited render is
uploaded over a proof (#745).
So after a round-trip the exports named the render. Each of these formats
exists to help a photographer find the master on disk, and the render's name
does not. The XMP case is the sharpest: the sidecar is written next to a RAW
master, so a wrongly-named one is never associated with it.
Two helpers, because the sites want different things when nothing is known:
cameraName() source_filename || original_filename || null
cameraFilenameOrStored() the above, else the stored name
The dedicated `original_filename` fields (CSV column, JSON key) keep reporting
blank/null when unrecorded — echoing the sanitized stored name there would
invite a match against a file that does not exist under it. The places that
must emit some name (text list, CSV filename cell, XMP sidecar) fall back to
the stored one, as they did before.
filename_format='stored' is untouched, and rows with no source_filename still
resolve to original_filename, so nothing moves for installs that have never
run a replacement.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Both feedback exports carried only `photos.filename` — the sanitized stored
name (`wedding-smith_individual_1755892345.jpg`). Acting on client picks means
finding the master on disk, and that name matches nothing in a Lightroom
catalog, so the export could not be joined to anything.
Adds the camera-original name to the long and pivot shapes, and by extension
to the archive's feedback_data.csv/.json, which reuse the same query.
COALESCE(source_filename, original_filename), not original_filename alone:
the latter is overwritten the first time an edited render is uploaded over a
proof (#745), so an export taken after a round-trip would name the render
rather than the master and silently stop matching. source_filename is written
once at ingest and survives a replace by design (migration 193). That case is
the load-bearing test.
Aliased to `original_filename` — the name the sibling photo export already
uses for this column, and the question the reader is asking. Left empty when
neither is known rather than echoing the stored name: blank reads as "no match
possible", where repeating the sanitized name invites a match attempt against
a file that does not exist under it.
The column is added, not swapped: `filename` is untouched, so anything reading
the old column keeps working.
Reported by the 8digit/picpeak fork, which has carried a narrower version of
this patch (original_filename only) across rebases.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(api): Lightroom round-trip — read marks, put edits back (#745)
Gets a client's proofing verdict into a desktop catalogue and a finished
edit back over its proof, without anyone re-matching files by hand.
Three parts:
**Keep the camera filename.** photos.original_filename is the only carrier
of `IMG_1234.JPG` — the stored filename is rewritten by
generatePhotoFilename. But replacePhoto() overwrites original_filename with
whatever name the new file arrives under, so the first re-upload of a
renamed render destroys the key the NEXT round-trip needs. Migration 185
adds photos.source_filename, written once at ingest and never touched by a
replace, backfilled from original_filename so existing galleries can still
match on their first pass. The backfill sits outside the column guard and
keys on whereNull, so a run that dies partway self-heals instead of leaving
half the rows empty forever.
**Read the marks.** GET /api/v1/events/:id/photos returns each photo with
its client colour tallies, the caller's own marks, and a merged colour +
rating. Guards copied from the sibling upload route (apiTokenAuth +
read scope + photos.view + requireEventOwnership). Filters: marked_only,
mark_source, color_labels, my_color_labels, min_rating, my_min_rating.
The route filters to a page of ids with PhotoFilterBuilder, then enriches
just those through photoExportService.getPhotosWithFeedback — the two
halves already existed and neither does both, and going id-first keeps the
per-colour tally query bounded by page size.
services/markMerge.js decides how three possible opinions (guest colours,
guest star average, the photographer's own row in photo_admin_marks)
collapse into the one colour and one rating Lightroom has room for. Colour
goes to the photographer on a tie — one deliberate choice beats an
aggregate a tie-break already had to guess at. Rating takes the max,
because a rating is a magnitude and losing the higher one quietly demotes
a photo somebody rated highly. Its roundRating matches
XmpGenerator.mapRating exactly so the API and an XMP sidecar can never
disagree about how many stars a photo has.
**Put the edit back.** POST /api/v1/events/:id/photos accepts an optional
replaces_photo_id and routes to the existing replacePhoto(), preserving
the photo's id, feedback, comments and position. The plugin stores the
picpeak id on the catalogue photo, so the id survives the editor renaming
the render — which makes it the reliable key, not the filename. Scoped to
the event in the URL: a token inherits its owner's powers across every
event they can see, so an unscoped id would let one gallery overwrite
another's photo.
For renders whose RAW never went through the plugin, findReplacementCandidate
gains an opt-in number_token mode matching on the trailing digit run.
Deliberately the LONGEST run and never a fixed last-N slice: multi-camera
shoots disambiguate by prefixing the camera index into the number
(cam11234.jpg / cam21234.jpg), and a last-4 slice reads 1234 from both
bodies and reintroduces exactly the collision the prefix removes.
Ambiguity is refused, never guessed.
Also drops the multer temp file on the two new early returns — this route
only unlinks in its catch block.
* refactor(api): one rating-rounding rule, and apply match_mode where it counts
Three things the pre-review pass turned up on the round-trip work:
- `match_mode` reached the photo-cap pre-count but not the loop that
actually picks the replacement target, so asking for `number_token`
would have been counted and then quietly ignored. Both call sites now
take it.
- `number_token` matching read `select('*')` over every photo in the
event to compare one digit run. It now reads the three columns the
match needs and re-reads the single winner in full, so a 5000-photo
event doesn't pull 5000 full rows through memory to answer one
question.
- `XmpGenerator.mapRating` and `markMerge.roundRating` were the same
five thresholds written twice — the second way to do one thing that
drifts the moment either is touched. The thresholds now live in
markMerge and the generator delegates, which is what keeps a sidecar
and the v1 API from ever disagreeing about a photo's star count.
* fix(api): keep the new route in the generated OpenAPI spec
The `color_labels` description carried an inline JSON example. In an
unquoted YAML scalar `{ "green": 2 }` parses as a flow mapping, so
swagger-jsdoc threw YAMLSemanticError and dropped the WHOLE route from
the spec — visible only as a warning on boot, with the route still
serving normally, which is exactly the kind of failure that survives to
release.
Found by booting a real instance rather than by reading the diff.
* fix(api): close the four blockers from review on #1165
1. Replacing an external photo silently kept serving the old file.
resolvePhotoStorageKey gives photo.source_origin precedence and
returns null for 'reference'/'external', so the edit was uploaded,
the row updated and 200 returned while every viewer kept getting the
untouched NAS original and the upload sat orphaned. replacePhoto now
repoints the row to managed and clears external_relpath. The file on
the share is never touched — this moves the pointer, not the data.
2. Every replacement leaked its temp file. putFromFile COPIES on local
and uploads on S3; neither consumes the source, and replacePhoto
never unlinked it — while the v1 route had disabled its own cleanup
on the belief that replacePhoto moved the file. Cleanup now lives in
replacePhoto, which closes the admin path too (adminPhotos only
unlinks in its new-files branch, so replaced files leaked there as
well). The v1 route also unlinks on the FAILURE path, which returned
before any cleanup ran.
3. The download-all ZIP is invalidated after a replacement, as
adminPhotos.js already does. Without it guests kept downloading the
pre-edit photo indefinitely, which defeats the point of the feature.
4. The round-trip could not see reference or watcher galleries at all.
fileWatcher and adminExternalMedia never set original_filename — the
camera name lives in `filename` for those rows — so the backfill and
the GET fallback both produced NULL for exactly the galleries most
likely to be driven from Lightroom. The backfill now COALESCEs, both
ingest paths set source_filename, and the GET falls back to filename.
Concerns:
- number_token no longer reads every photo row in the event per file. A
LIKE on the digit run narrows the candidate set in SQL first; the
exact trailing-run check still decides, so semantics are unchanged.
The token is a regex-extracted digit run, so it cannot carry a
wildcard.
- The replacement's activity entry is scoped to event.id instead of
null. The dashboard feed excludes NULL-event rows for scoped callers
(GHSA-jhcf), so it was vanishing from the audit trail of the
photographer who owns the event.
Nit: dropped the unused higherPriorityColor export from markMerge.
Three regression tests cover the external repoint, the temp cleanup and
the COALESCE backfill. 21/21 pass.
* chore(migrations): renumber 185 -> 193 after gallery-folders landed
185_add_category_is_folder.js merged to main while this was in review,
so the number the PR reserved is taken and main is now at 192. Knex keys
on filename rather than the prefix, so both would have run — but
picpeakImportService guards restores with migrationOrder(), which parses
that prefix, and two files answering 185 make the forward-only check
pass a backup onto a schema missing its columns.
Renumbered with every reference: the header comment, the test that
requires the path, and the four call-site comments that cite it. The
'migration 182' reference inside it is the colour-labels migration and
is unrelated; gallery.js:1134 cites upstream's 185 and is untouched.
* fix(api): keep external_relpath when a replacement converts the row
The external-photo blocker fix cleared external_relpath along with
flipping source_origin, which closed one hole and opened another.
adminExternalMedia dedupes a re-scan on (event_id, external_relpath)
— routes/adminExternalMedia.js:195 — and migration 186 puts a unique
index on exactly that pair. With the column nulled, the next scan of
the share would not recognise the NAS original as already imported and
would insert it again, so the gallery would end up holding both the
edit and a fresh copy of the file it replaced.
Only source_origin needs to change: it is what resolvePhotoStorageKey
keys on, and every other consumer of external_relpath reads the two
together and lets source_origin decide. The stale relpath on a managed
row is inert for resolution and still correct as a dedupe key.
Test updated to assert the value is kept rather than cleared.
* fix(uploads): say when exiftool is missing instead of blaming the RAW
A server without exiftool reported `No usable embedded preview in RAW
file X.CR3: spawn exiftool ENOENT` for every RAW upload. The headline
describes a corrupt photo; the actual cause is a package that was never
installed, demoted to a trailing detail. It sends people hunting through
their camera files.
Hit while testing the Lightroom round-trip (#745): an export of RAW
originals failed 11 times with that message, and the file was fine.
RAW upload is the only feature that needs exiftool, so an install can be
missing it indefinitely and only find out when someone uploads a CR3 —
which makes the wording the whole diagnosis.
ENOENT now produces a message naming the dependency and the install
command for Debian/Alpine/macOS, and breaks out of the tag loop instead
of spawning the same missing binary twice more to report the last
failure as if it described the photo. A genuinely preview-less RAW still
gets the original message.
Verified both paths by making exiftool unreachable via PATH rather than
mocking: missing tool and unreadable file now report differently.
* fix(external): a delivered edit must win a relpath-fold collision
Follow-up to keeping external_relpath on a replaced photo. Keeping it is
what lets adminExternalMedia still dedupe the folder re-scan, but it also
leaves the row inside externalRelpathFold's sweep — and that sweep does
not merely rewrite paths, it DELETES collision losers via
externalPhotoDedupe.
The survivor was whichever row happened to be claimed first, which is
iteration order. So a replaced photo — source_origin 'managed', holding
the edit the photographer just delivered — could be deleted in favour of
the untouched camera original sitting next to it on the share.
Managed rows now claim first and therefore survive. The external row
that loses is the recoverable one: it is still on the share and a
re-scan re-imports it. The edit is not recoverable.
Note this is deliberately NOT the "skip managed rows in the fold"
shape suggested in review. Skipping would leave those rows holding a
base-relative path while every other row moved to root-relative, so the
scanner — which computes root-relative — would stop matching them and
import the camera original again as a duplicate. That is the exact bug
keeping external_relpath exists to prevent, reintroduced through a
different door. Rebasing them and protecting them from deletion keeps
both properties.
* fix(gallery): make the returning-guest recovery findable (#1210)
A guest who fills the registration form in again becomes a second
gallery_guests row, and their earlier likes and favourites stop counting as
theirs. Recovery has always existed to prevent exactly that — as a small link
under the submit button, which people reasonably read as fine print and
skipped, so duplicates kept accumulating even for guests who had given an
email the first time and were eligible for it.
Given its own block below a divider, and worded around what the guest loses by
missing it: 'Been here before? Your earlier picks are still saved.' rather than
'I've been here before', which reads as a greeting rather than a reason to
stop. The affordance itself becomes 'Get them back'.
Still a choice the guest makes, not a check the server runs. Looking up whether
the typed address is already registered would answer 'is this person in this
gallery' to anyone who asked — which is why /guest/recover always returns 200
and cannot be used that way.
The alreadyHere key is retired rather than reworded: a key by that name holding
'Get them back' would mislead the next translator. Both new strings are in all
seven locales that carried the old one.
Three tests: the hint is present, the affordance routes into recovery rather
than registering, and an ordinary first-time registration is unchanged.
* fix(i18n): match the German formality in the returning-guest hint (#1210)
The dialog addresses the guest as Sie throughout — "Willkommen — wie heißen
Sie?", "Ihre Auswahl wird unter diesem Namen gespeichert" — and the new line
came out in du. Mixing the two in one modal reads as sloppy to a German
speaker.
Caught by looking at the rendered dialog rather than the string, which is the
argument for screenshotting a copy change at all.
* fix(gallery): theme tokens for the recovery block, formal register in nl (#1210)
External review of #1217.
**The dark variant never fires in a gallery.** A dark gallery preset is
delivered through CSS variables; ThemeProvider does not add Tailwind's .dark
class. So `text-neutral-600 dark:text-neutral-400` on a dark surface stayed
dark grey on dark, and the divider stayed light. My block was the only place in
this modal using neutral-* classes at all — the rest already uses text-theme
and text-muted-theme for exactly this reason. The divider now takes
--color-surface-border, which is the token index.css actually defines.
**Dutch had the same mixed register German did.** The dialog says uw/u
throughout — 'wat is uw naam?', 'Uw selecties worden opgeslagen' — and the new
hint came out with 'Je'. Same slip, same fix, found the same way.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(admin): shift-click range selection in the photo grid (#1212)
Selecting photos was one tile at a time. Select All is all-or-one, so
're-assign these two hundred' meant two hundred clicks — which is how #1209
ran into it, re-categorising a large imported set.
Shift-click now selects the span from the last plain click to the tile under
the cursor, the way a file manager does.
It extends the selection rather than replacing it: the grid already lets you
accumulate tiles one at a time, so a range is another addition to that set. And
it only ever adds — deselecting by dragging a range back over itself is a
different gesture, and guessing at it would let a mis-aimed shift-click destroy
a selection instead of growing it. The anchor stays put across repeated
shift-clicks, so the second one re-aims the same span from the original point
instead of walking along behind the cursor.
The anchor carries the id of the tile it was set on, not just the index. An
index means a different photo after a filter or a re-sort, and a range measured
from a stale anchor would select the wrong span with nothing to show for it;
the write checks the anchor still points where it was set and falls back to a
plain toggle when it does not. Validating at use rather than clearing on every
list change means a background refetch, which hands back an equal list, leaves
the anchor usable.
Seven tests, four of which fail without the change; the other three pin the
plain-click and no-anchor behaviour that must not move.
* fix(admin): clear the range anchor whenever the selection is cleared (#1212)
External review. Cancel Selection, Deselect All and a successful bulk move or
delete all emptied selectedPhotos and left the anchor behind. The anchor is
invisible, and the list is usually unchanged, so it stayed valid — the next
shift-click reached back into a selection session the user had already ended
and selected a range they never started.
Cleared at all four reset points now. Test fails against the un-fixed code.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(guests): surface duplicate guest registrations, and stop making so many (#1210)
Guest registration always inserts. A client whose token expired — or who opens
the gallery on a second device — becomes a new gallery_guests row, and their
likes and favourites split across the copies. The photographer's 'final
selection' is then only trustworthy if somebody notices two Tinas with half the
picks each.
Two halves, neither of which touches the registration path.
**Say which rows are the same person.** Merging already worked, endpoint and UI
both; nothing said WHICH rows to merge. The guests list now marks each row with
the others sharing its email and returns a count for the banner, and the admin
list offers the group straight to the merge mode that already exists.
Case-folded and trimmed, because the same person types Tina@ one day and tina@
the next and both read as distinct rows. Email only — two guests called Anna
are not evidence of anything, and rows without an email are not grouped at all
since require_name_email is off by default and a shared link produces plenty of
them.
It preselects rather than merges: which row survives decides the name and
verification state the merged guest keeps, and that is the admin's call.
**Create fewer of them.** The guest token was 24h and every call site took that
default, so even the same browser lost its identity after a day of inactivity.
Now 30 days, GUEST_TOKEN_TTL to override. A guest token is scoped to one event,
carries no admin capability, and the gallery is already behind whatever
protects it — 30 days is the shape of a real proofing cycle.
Deliberately NOT done: reusing a guest row when a typed email matches, which
the report suggests first. It would let anyone who knows an address inherit
that person's identity and selections, and answering differently for a known
email would leak which addresses are in the gallery — the thing
/guest/recover already goes out of its way to avoid. Prevention at the entry
path needs the verification round-trip, which is a separate decision about
friction.
13 tests; 8 of the 9 backend ones fail without the change. The frontend ones
caught a real bug while being written — the new useMemo sat after the loading
early-return, so the hook count changed between renders.
* fix(guests): merge must not strand a pending invite (#1210)
Three findings from external review of #1216.
**A merge could kill an emailed invite link.** Creating an invite inserts a real
gallery_guests row, so an admin who pre-mints one and then sees the guest
self-register has two rows sharing an email — which this feature now points out
and offers to merge. Redemption resolves guest_invites.guest_id with
is_deleted: false, so merging soft-deleted the row the link pointed at: the
client got 404 guest_missing while the invite dialog still showed the invite as
Pending. Nothing anywhere said the link was dead. Unredeemed, unrevoked invites
now move to the survivor first. Spent ones stay put — a redeemed invite records
who redeemed what, and retargeting it would rewrite that.
**The preselection silently chose the survivor.** performMerge keeps
mergeSelection[0], and the group was handed over in API order, which is
newest-first — so Review then Merge discarded an older, email-verified row
holding most of the picks in favour of a fresh re-registration. The proposal is
now ordered deliberately: verified first, then whoever holds the most feedback,
then the oldest. Still only a proposal, and the confirmation now names the
survivor by email as well as name, because duplicates share a name and 'Merge 2
guests into Tina?' said nothing.
**duplicate_of was quadratic.** Every row carried the other n-1 ids, so a group
of n serialised n² of them — and nothing consumed the list: the UI asked only
whether a row was in a group, then regrouped by email itself. Replaced with
duplicate_group, the normalised email, which keeps the payload linear and the
case/whitespace folding in one place instead of reimplemented on the client.
Two new backend tests for the invite paths, one frontend test asserting the
merge call keeps the verified row. The invite test fails against the un-fixed
code.
* fix(guests): keep guest-controlled input out of who survives a merge (#1210)
Round 2 of external review on #1216.
**The survivor ranking used an attacker-controlled signal.** Preferring
whoever holds the most feedback looked like the obvious tiebreak and is exactly
the wrong one: registration does not verify the address, so anyone who knows a
guest's email can register with it, mark enough photos to out-rank the real
person, and be preselected as the survivor. An admin accepting a confirmation
between two rows with the same name and email would then move the victim's
picks onto an identity whose token the visitor still holds. distinct_photos is
guest-controlled and has no business deciding this. The ranking is now
email_verified_at then created_at — both server-set.
**A merge could make the survivor unrecoverable.** Rows are grouped with case
and whitespace folded out, so a merge can be proposed between tina@example.com
and Tina@Example.com. /guest/recover lowercases what the guest types and then
matches on equality, so a survivor left holding the raw value can never be
recovered by email again. The kept row's address is now canonicalised during
the merge. Both write paths normalise today, so this covers rows that predate
that — which are exactly the rows case-folded grouping surfaces.
Two more backend tests. The residual, stated plainly: an admin can still merge
two unverified rows in either order. What is gone is the tool ranking them by
something a visitor controls.
* fix(compose): pass GUEST_TOKEN_TTL through to the backend (#1210)
The override was documented in .env.example and could never take effect: the
backend service takes an explicit environment list, so a variable not named
there never reaches the container. An operator following the documentation
would have shortened the guest session and seen nothing change.
docker-compose.production.yml uses env_file: .env and already passed it
through; docker-compose.dev.yml is gitignored, so only this file needs it.
* fix(guests): the admin picks the merge survivor, the tool does not (#1210)
Fourth review round on the same point, and the right conclusion is that there
is no correct automatic answer.
Every rule tried was wrong somewhere. Most-feedback is guest-controlled — the
address is never verified at registration, so anyone who knows it can register
and mark photos until they out-rank the real person. Oldest-first, the
replacement, is worse for the ordinary case: when a token expires the OLD row
is the dead identity and the new one is the visitor's live session, so keeping
the oldest deletes the identity they are actually using, and the frontend holds
that deleted guest in sessionStorage without clearing it on a 401. Registration
timing is visitor-controlled too.
The data does not say which row is really the person. So the UI asks: merge
mode gains a Keep column, the button stays disabled until a row is nominated,
and only rows included in the merge can be nominated. The group is still
preselected — finding the duplicates was always the point — but nothing about
who survives is decided by sort order any more.
This also makes the claim in the PR description true. It said the admin decides
which row survives; until now the preselection quietly decided it for them.
Two rewritten frontend tests: the merge is blocked until a survivor is chosen
and then keeps exactly that row, and a row outside the group cannot be
nominated. The test i18n mock now interpolates, so aria-labels are queryable by
their rendered text.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(feedback): a third identity mode with one shared colour tag per photo (#1197)
Split out of #1178, where @boergu asked for a colour tag with no identity
dimension at all: not everyone sharing a device's state, but everyone — on any
device — sharing the PHOTO's state. Guest A marks it green, guest B later marks
it orange, and the tag simply becomes orange. One collaboratively-agreed verdict
per photo instead of per-person tallies.
identity_mode gains 'shared'. The mode is scoped to the colour tag: likes,
ratings, comments, favourites and reactions stay per-visitor exactly as in
'simple', because that is what was asked for and widening it would change what
every other control means.
Stored as an ordinary photo_feedback row under a reserved identifier rather
than as a column on photos. That is what keeps the rest of the system working
untouched — the per-colour tally simply has exactly one entry, so
dominant_color_label, color_label_count, the admin colour filter and the
XMP/CSV export that #745 reads all keep their existing shapes, and no consumer
has to learn a second one. The identifier cannot be claimed: real ones are
sha256 hashes or server-minted UUIDs, and the per-guest write path rejects it
outright.
Last write wins, inside a transaction that locks the photo row. Without the
lock two guests tapping different colours in the same instant both read 'no
tag', both insert, and the photo ends up carrying two shared tags — the
per-guest tally this mode exists to remove. Re-sending the colour already on a
photo clears it, from any guest: the same toggle every other colour path uses,
and the only way to remove a tag without inventing a second control.
Switching modes is non-destructive. Existing per-guest labels are left alone
and simply not read while shared is on; the shared tag starts empty rather than
collapsing marks nobody agreed on, and switching back restores every original
exactly. An event can hold both sets, only one of which is live.
The tag stays visible with show_feedback_to_guests off — it arrives through the
per-viewer channel, being the photo's own state rather than someone else's
opinion — while the per-colour tallies stay hidden. The colour filters answer
from it for the same reason, so a gallery with sharing off cannot show colours
on tiles that no filter can find.
Attribution is gone by design, and the settings panel says so before an
operator picks the mode.
Decisions (1), (4) and (5) from the issue were settled up front, as it asked.
Decision (3) turned out not to need anything: guest colour filters already read
my_color_label, and the admin's my_color_labels filters photo_admin_marks
(#1183), not guest identity — so nothing collapses on either side.
* fix(feedback): shared mode saves on Postgres, and dormant labels stay dormant (#1197)
Three findings from external review, all confirmed against source before fixing.
**The mode could not be saved on Postgres at all.** Migration 078 created
identity_mode with a CHECK constraint pinned to ('simple','guest'), guarded on
`client === 'pg'` — so SQLite never has it and no SQLite test can see it, while
the database every default production install runs rejects the new value
outright. Migration 192 drops and re-adds the constraint with 'shared' included;
its down() resets any event using the mode to 'simple' first, or the narrower
constraint could not be restored. Verified against a real Postgres on a scratch
database: the insert fails before, succeeds after, up() is re-runnable, and
down() puts the old constraint back.
**Dormant labels were still being read.** Switching modes is deliberately
non-destructive, which leaves both sets of colour labels in the table with only
one live — and every read that did not say which set it meant kept counting the
other. The per-colour tallies, color_label_count, the admin grid badge, the
XMP/CSV export, both admin colour filters and the guest colour filter all saw
labels the mode does not show; switching back exposed the shared row as an
anonymous other guest's dot. The settings panel promises these are 'kept but not
shown', and that has to mean every surface, not just the badge. Scoped at the
source — the two count helpers resolve the mode themselves — so the admin grid
and the export are fixed without touching either.
**The create form's identity mode was dropped.** CreateEventPage has always
rendered the chooser and the create route never read it, so a gallery created as
'guest' came out 'simple' and had to be set again on the event afterwards. A
pre-existing bug that adding a third option made worse; threaded through now,
which fixes it for all three modes.
Six regression tests, each verified to fail against the un-fixed code.
* fix(feedback): keep every colour surface consistent across a mode change (#1197)
Second review round, four findings, all confirmed in source first.
**Stored counters went stale on a mode switch.** photos.color_label_count is
denormalized and recomputed on feedback writes, so changing identity_mode —
which changes nothing about the rows, only which of them are live — left the
old mode's totals on the tiles, the admin grid and the filter summary until
each photo happened to be touched again. On a finished gallery that is never.
Recounted for the event when the mode actually changes, as two statements
rather than a per-photo recompute: four of the five counters cannot have moved.
**Duplicating an event dropped the mode**, the same shape as the create-form
bug from the last round — a gallery cloned to reuse its proofing setup came
back in 'simple'.
**The event feedback summary counted dormant labels**, inflating total_feedback
in the admin analytics and the guest /feedback-summary while every other
surface hid them.
**The swatch trusted its optimistic guess over the server.** In shared mode the
tag belongs to the photo, so another guest can move it between this viewer's
last read and their click: a viewer still showing green clicks green, the
server sets green because the tag had become red meanwhile, and the optimistic
'same colour, so clear' blanked the swatch against a server that holds one. The
response already says which happened, so it is used. The per-guest modes are
unaffected — only the guest can move their own label, so guess and answer
always agreed there.
Three regression tests, each verified to fail against the un-fixed code.
* fix(feedback): shared tag is not a participant, and the keyboard path reconciles too (#1197)
Third review round, two findings.
**feedback_count counted the shared tag as a guest.** It is COUNT(DISTINCT
guest identity) across all feedback types, and the reserved identifier looked
like a person: a photo with one rating and a shared tag reported two. The
column is exported as rating_count (photoExportService), so merely tagging a
photo inflated its rating count in the CSV and JSON exports.
**The lightbox keyboard path still trusted its own guess.** The reconciliation
from the last round covered clicks through PhotoColorLabels, but the proofing
shortcuts call PhotoLightbox.submitColorLabel directly and set local state from
a locally computed toggle. That is the path a proofing client actually uses, so
it had the divergence the previous fix was for: another guest moves the tag,
this viewer presses the key, the server sets a colour and the swatch blanks.
Both branches now read the outcome off the response.
One regression test, verified to fail against the un-fixed code.
* fix(feedback): identity-mode lookup must survive a migration-time caller (#1197)
updatePhotoFeedbackStats is called from migrations as well as from the request
path — migration 186's duplicate-photo dedupe (#1162) recomputes the survivor's
totals — and a migration runs against a half-built schema where
event_feedback_settings need not exist yet. The new inner join threw there,
which took the whole stats update down with it, so the reparented rows were
never counted and eight assertions in the 186 suite failed.
Falls back to 'simple', which is the right answer rather than merely a safe
one: an install with no feedback settings table has no event in shared mode, so
the non-shared scope is exactly correct.
Caught by CI, not by me — I had been running affected suites rather than the
full one after each review round.
* fix(feedback): atomic shared-tag write, scoped feedback list, safe PG fallback (#1197)
Round 4 of external review, and one of the three is about the fix I made for
the CI failure two rounds ago.
**The identity-mode fallback could poison a Postgres transaction.** The join
was wrapped in try/catch so a migration-time caller with a half-built schema
would fall back to 'simple'. On Postgres a failed statement aborts the entire
transaction, so catching it and carrying on left the caller's trx poisoned and
the aggregate that follows failed with 'current transaction is aborted' —
defeating the very compatibility the fallback was added for. It now asks
whether the table exists before issuing the join, which is safe to ask and
aborts nothing. Memoised once true, since a table does not un-create itself and
this sits on the feedback write path.
**The shared-tag stats were recomputed after the commit.** A failure there
returned 500 for a tag that had already been written, so the client reverted
its swatch and the next tap on the same colour toggled the committed tag off
instead of setting it. Two concurrent writers could also race their aggregate
updates. Recomputed inside the transaction now, while the photo row is still
locked.
**The raw feedback list still carried both label sets.** Only the tallies and
my_feedback had been scoped, so a dormant per-guest label was still visible to
anyone reading the list — and with sharing off it came back flagged is_mine.
getPhotoFeedback now filters colour labels to the active set.
One test for the list; the migration suite that caught the original CI
regression still passes.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(setup): put the setup token where a NAS user can find it (#1218)
The token file was never missing — it was in a subdirectory nobody opens. The
all-in-one image points DATA_DIR at /data/db, so the file lands beside the
database inside the single volume; someone browsing that volume from a NAS
container UI sees db/, storage/, logs/, backup/ and gives up. There is no shell
on those boxes to run the documented `docker exec … cat` with, and the token
value is deliberately kept out of the logs, so the install looked like it had
swallowed its own bootstrap credential.
When DATA_ROOT names a different directory, the token is now written there too
— /data/SETUP_TOKEN, the first thing visible on opening the volume. The compose
stack sets no DATA_ROOT and keeps exactly one file, so nothing changes there.
Each copy is written independently: the canonical one failing while the
volume-root copy succeeds still leaves a readable token, and only a run where
every write failed falls back to logging the value. The startup banner names
every copy rather than just the first, which is what sent people into db/.
Both copies are 0600 and both are removed the moment setup completes. That is
what makes a second copy of a single-use bootstrap secret acceptable rather
than careless — and writing the test for it turned up that the burn path had
TWO independent unlinks, one in clearSetupToken and one at the end of
createInitialAdmin. Only the first had been updated, so the volume-root copy
survived the burn: a live-looking token that no longer works, which is worse
than no token at all.
Docs for the same issue are already out (PicPeak/docs#15); .env.example now
names the AIO paths too.
* fix(setup): enforce 0600 on a token file that already exists (#1218)
External review. fs.writeFileSync's `mode` applies only when the file is
created — writing over an existing inode truncates it and leaves its
permissions untouched. A SETUP_TOKEN someone had copied to the volume root by
hand at 0644 would keep that mode, so the first-admin bootstrap credential sat
group- and world-readable on a shared NAS mount while this code claimed 0600.
Unlink then create, rather than chmod after write: recreating gives a fresh
inode with the right mode and no window where the credential is on disk under
the wrong one. The chmod stays as a fallback for an unlink that failed for a
reason other than the file being absent.
Test fails against the un-fixed code.
* fix(setup): drop a token copy that cannot be made private (#1218)
Round 2 of external review. Asking for 0600 is not the same as getting it: a
CIFS/SMB mount — which is what a NAS commonly offers — carries no Unix modes,
so chmod is a silent no-op and the file keeps whatever file_mode= the mount
forces, typically 0644. This feature targets exactly those hosts, so it now
verifies the resulting mode instead of assuming the request took.
A copy that cannot be made private is removed rather than left lying there, and
it does not count as written — so an install where neither copy can be
protected falls through to the existing log fallback, which reaches the
operator alone. Previously a chmod that threw after a successful write left the
credential on disk, and a success on the other path cleared the error, so
nothing reported the exposed copy at all.
Test simulates the mode-less mount with chmod as a no-op and stat reporting
0644; it fails against the un-fixed code.
* fix(setup): never write the token through a foreign inode, or into the logs (#1218)
Round 3 of external review, two findings, both about the credential ending up
readable by someone else on exactly the shared mounts this feature targets.
**The log fallback defeated the point.** When no copy can be made private, the
old branch logged the token at warn — and logger.js writes warnings to
combined.log under LOG_DIR, which in the all-in-one image sits on the same
mount as the token file. The credential moved from a file we had just refused
to leave, into another file just as readable, that outlives setup. The warning
no longer carries the token; server.js already prints it on stdout when no file
was written, which reaches `docker logs` without touching the shared volume.
**A file that could not be deleted was written through anyway.** The
pre-write unlink swallowed every error, so a 0666 SETUP_TOKEN owned by another
user in a sticky or ACL-controlled directory — still writable — received the
live token into its existing inode. Only ENOENT is ignored now. And when the
mode check finds an exposed copy it cannot remove, that is recorded separately
and reported at error level: a success on the other path clears writeError, and
an exposed credential must not be silenced by an unrelated success.
Two tests, both failing against the un-fixed code.
* fix(setup): fail closed on an exposed token, and refuse a raced symlink (#1218)
Round 4 of external review.
**An exposed copy left the token valid.** A directory that permits creation and
denies deletion — ACL-backed or CIFS — could keep a group/world-readable file
holding a live setup token, and /setup/admin went on accepting it: anyone able
to read the mount could take the first super-admin account. Reporting that was
not enough. The token is now revoked when a readable copy cannot be removed,
which turns what is left on disk into a dead string. Private copies are removed
with it, since they hold the same value. The next boot mints a fresh one and
skips the undeletable file rather than rewriting it, so this converges instead
of looping on the same exposure.
**The write followed a raced symlink.** On a group-writable mount another local
user could drop a symlink at the path between the unlink and the write, and the
default 'w' flag would follow it — putting the live token in a file they own.
Now created with 'wx' (O_CREAT|O_EXCL), which neither overwrites nor follows a
link; having just unlinked, anything present again is that race. The mode check
uses lstat for the same reason: it must describe the file, not a link target.
**A verification that threw left the file behind.** writeFileSync succeeding and
lstat then failing — plausible on the network filesystems this targets — left an
unverified live copy on disk, and a success on the other path cleared the error
so nothing said so. Cleanup is now keyed on 'did this iteration create a file',
so every post-creation failure removes it.
Three tests, one new; the new one fails against the un-fixed code. Full backend
suite at the known baseline.
* fix(setup): report the written token path again, so the banner stays quiet (#1218)
A regression I introduced one commit ago. Rewriting the write loop dropped the
three lines after it that publish the result, so writtenTokenFile stayed null
even on a completely successful write.
server.js prints the token itself only when no file was written. With this
reporting nothing, the banner took that failure branch on every fresh install
and put the live super-admin setup token into stdout and `docker logs` — beside
a perfectly good 0600 file. That is the exact leak this path was built to
close, reopened by a refactor that touched none of the logic around it.
Found by external review, not by the suite: nothing asserted the accessor, only
the files on disk. Now guarded — the new test fails against the regression.
* fix(setup): survive a worker race, and revoke a copy that predates this run (#1218)
Round 6 of external review.
**A pre-existing exposed copy was invisible to the revocation.** A restart
reuses the token from the database, so an old file holding that value is a live
credential. If it had become group-readable and could not be deleted, nothing
tracked it — created was false, so the fail-closed path never fired and
/setup/admin kept accepting what was in that file. An undeletable file at the
token path is now treated as live and triggers the same revocation.
**A losing worker printed the token.** The shipped PM2 cluster config runs
several workers against one DATA_DIR. Both pass the unlink, one wins the
exclusive create, and the loser's wx write threw EEXIST — so it recorded
nothing and its banner printed the live token into its own log while a
perfectly good 0600 file already existed. EEXIST now checks the file: private,
regular, and holding the same token counts as this loop's work already done.
**A write that created the file and then threw left it behind.** ENOSPC, a
short write, a delayed close on a network mount — writeFileSync can populate
the inode before failing, and cleanup keyed on the call returning skipped it.
Keyed on the write being attempted now, with an existence check.
Two tests, both failing against the un-fixed code. Full backend suite at the
known baseline (2342 passing).
* refactor(setup): drop the volume-root token copy, keep the hardening (#1218)
The second copy was for discoverability: DATA_DIR points into /data/db on the
all-in-one image, and a NAS user browsing the volume does not open a folder
called db. Six review rounds later it had earned a second inode to race, to
verify, to clean up and to revoke — a symlink guard, an exclusive create, an
lstat check, cluster-race handling and fail-closed revocation, nearly all of it
load-bearing only because there were two files instead of one.
That is a lot of attack surface for a convenience the documentation covers
better. PicPeak/docs#15 now points NAS users at ADMIN_PASSWORD, which creates
the admin on first boot and needs no file at all, and names the db/
subdirectory for anyone who does want the token. Neither needs a second copy.
So: one file in DATA_DIR again, as before. Everything the review turned up
stays, because none of it was about the second copy — the token is created with
O_CREAT|O_EXCL so a raced symlink cannot capture it, its mode is verified with
lstat rather than assumed, a copy that cannot be made private is removed, one
that cannot be removed revokes the token instead of being logged about, a
partial write is cleaned up, a concurrent worker's good file is accepted rather
than triggering the log fallback, and the token never reaches the log files.
setupTokenFilePaths and writtenSetupTokenFiles are gone with their tests; the
hardening tests remain and still fail against unfixed code.
* fix(setup): publish the token atomically instead of racing over one inode (#1218)
Round 7 of external review found a race in the exclusive-create approach: two
PM2 workers reaching the write together, the loser sees the winner's file after
the inode exists but before its content lands, judges it wrong, and deletes it
— after which the winner's own verification fails too, both report nothing
written, and both print the live token into their logs.
Rather than teach the loser to wait, the shared inode is gone. The token is
written to a per-process temporary file, verified there, and published with
rename(2). That is atomic: the file never appears at the published path with
the wrong mode or half its content, a symlink sitting at that path is replaced
rather than followed, and concurrent workers simply publish the same value one
after another. The unlink-then-create dance, the EEXIST handling and the
cross-worker deletion all disappear with it.
Verifying the mode BEFORE the rename is the stronger order too: a credential
that cannot be made private on a mode-less mount now never reaches the
published path at all, instead of being written and then cleaned up.
If publishing fails and something is still sitting at the token path, it is
treated as a live credential we could not replace, and the token is revoked —
unchanged in intent from the previous round, simpler in mechanism.
* fix(setup): drop a dead assignment and an unused import (#1218)
Both flagged by the code-quality review on #1219. `createdTmp = false`
after rename(2) is never read — rename consumes the temp file, so the
catch has nothing left to clean up either way. `os` was never used in
the test.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The dropdown offered the filter and it never worked. It rendered as
`value="0"`, and adminPhotos.js skips '0' outright:
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
so no category condition was applied and the whole event came back. Four lines
below that guard sits the branch that does the work, keyed on the literal
'uncategorized' — which nothing was sending. The two ends have never agreed on
the wire value, and neither is wrong on its own.
It fails silently, which is why it went unnoticed: a full list reads as 'the
filter found nothing to narrow' rather than 'the filter did not run'.
Send what the backend already understands rather than teaching it a second
spelling. The onChange passes non-numeric values through unchanged, so the
string arrives intact.
Reported in #1209 by someone re-categorising a few thousand photos imported
without a category — the filter is the first step of filter, Select All, bulk
assign, so its failure takes the whole path with it.
Tests both ends of the contract, since the bug was the pairing rather than
either half: the frontend emits 'uncategorized', and the endpoint answers it
with only the null-category rows. The backend test also pins that 0 means no
filter, so a future change there has to be a decision rather than an accident.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): folders that contain photos instead of filtering them (#1160)
A category has always been a filter: its photos stay in the root grid and
picking the category narrows that grid. D#1086 asked for the opposite — put
the selects in a bucket and get them OUT of the main grid, so the client sees
the 40 finals and clicks through for the other 200.
`photo_categories.is_folder` makes that a per-category choice. One column is
enough because the neighbouring features already built the substrate:
hero_photo_id (#163) is the folder cover, allow_downloads (#640) is per-folder
download rules, display_order + event_category_order (#782) is folder ordering,
and photos.category_id being single-valued is already folder semantics.
Deliberately no parent_id. "Root -> Selects folder" is depth one, i.e. plain
containment; folders-inside-folders waits until someone asks.
Containment lands in the one useMemo where the category filter was already
applied, and the tiles render above the grid rather than inside a layout, so
all eight gallery layouts inherit folders without eight implementations.
Scope drives the counts too, so root reports 40 photos and not 240.
`?folder=<slug>` carries the open folder, preserving token and admin_preview,
so a folder is linkable and Back walks out of it instead of leaving the gallery.
Defaults to false, so every existing gallery keeps filtering exactly as before.
Folders are organisational, not access control: a foldered photo is served by
the same per-photo auth as any other. A test pins that, so nobody later mistakes
containment for a security boundary.
* feat(gallery): download a folder on its own, and label folders when moving photos (#1160)
Downloads now cover both halves of the requirement:
- the gallery-wide "download all" keeps zipping every photo including the
foldered ones (verified: 62 files), so a folder never quietly removes
photos from the client's one-click download;
- inside a folder there is a "Download folder (n)" button that zips only
that folder, once (verified: 20 files). It reuses /download-selected, so
there is no new endpoint and no second zip-building path.
The button honours the per-category opt-out (#640) both ways: a folder with
allow_downloads = false renders no button, and individual photos that opted
out are excluded from the id list rather than silently 403-ing mid-zip.
Moving photos into a folder already worked — a folder IS a category, so the
existing bulk "move to category" flow does it. What was missing is that a
folder and a filter category looked identical in that dropdown while having
very different consequences, so folder options now read
"<name> (folder — hidden from the main grid)". Threading is_folder through to
the dialog needed the admin category prop types widened; the data was already
on the wire.
* fix(gallery): folders were unreachable in the full-bleed layouts (#1160)
Containment comes from `filteredPhotos`, which BOTH layout branches use, but the
tiles were only rendered in one. On a Premium or Story gallery the foldered
photos therefore disappeared from the grid with no tile to click — moving 200
selects into a folder effectively deleted them from the client's view. The
folder nav is now built once and rendered by both branches, so a branch can't
hide photos without also offering the way in.
Those two layouts are edge-to-edge by design, and a block of cover cards above
the hero wrecks the opening they exist for, so they get a compact chip row
(`Folders [icon Selects 20]`) instead. It only renders when the gallery
actually has folders, leaving every existing full-bleed gallery byte-identical.
Also scopes the people strip to the photos on screen. `face_count` comes from
/people and spans the whole event, which contradicted the grid in two ways:
inside a folder a face read "12 photos" but filtered down to the handful in that
folder, and at root a person whose photos ALL lived in a folder showed up and
filtered to nothing — a dead chip. Recounted from `photo.person_ids`, which is
already what the filter itself uses, and zero-count people are dropped. No
backend change; the ids were on the wire already.
Verified in the running app: Premium renders the chip and navigates; the
lightbox counter inside a folder reads "1 / 20", not 1 / 62; the people strip
inside the folder drops from 12/11/4 to the one face actually present.
* fix(gallery): folder edge cases found in external review (#1160)
Seven issues, all verified against the code before fixing.
Unreachable folders (the serious one). `adminCategories` derives a slug with
`[^\w\s-]` stripping and `\w` is ASCII-only, so a valid name in a non-Latin
script slugs to the empty string — `Избранное` and `日本語` both do. Keying the
URL on the slug meant such a folder wrote no param and resolved to nothing: its
photos left the root grid with no way back to them. This repo ships ru and sl
locales, so that is a reachable state, not a hypothetical. Folders are now keyed
by `folderKey()` — slug when there is one, id otherwise.
Stale selection across a scope change. The grid clears its selection when
`categoryId` changes, which is already null at root, so a selection made outside
a folder survived into it and the toolbar would offer to download (or a client
to hide) photos no longer on screen. Cleared on both explicit navigation and
popstate.
Dead category chips inside a folder. The filter branch ignores
`selectedCategoryId` while a folder is open, so the chips did nothing when
clicked. They are no longer offered there.
"No photos found" beside folder tiles. A gallery whose photos all live in
folders rendered the tiles and then the grid's empty state directly under them,
claiming the gallery was empty while pointing at its contents.
Counts that contradicted the grid. The filter bar and both people surfaces were
still counting over every event photo, so a chip could advertise a total the
scoped grid would never produce. All now count over `scopedPhotos`.
`!!` on a validated boolean. express-validator's isBoolean() accepts the STRINGS
"false" and "0", and `!!'false'` is true — a form-encoded caller asking for a
filter would have silently got a folder. Uses the existing parseBooleanInput.
Duplicate-event dropped folder-ness. The category clone selected only name, slug
and is_global, so every folder in a duplicated gallery came back as a filter.
* fix(gallery): folder scoping gaps from external review round 2 (#1160)
Cache mutation, introduced by this branch. `photosInScope` returned the caller's
own array on the no-folders fast path, and `filteredPhotos` sorts in place — so
every gallery WITHOUT folders was reordering the React Query cache for every
other consumer of `data.photos`. The pre-branch code cloned; now it always does.
Colliding folder keys. UNIQUE is (slug, event_id), so a global folder and an
event folder can share a slug, and the gallery merges both scopes. Keying on the
slug alone meant the second folder resolved to the first and its photos could
not be opened. The id is now always part of the key.
"Download folder" downloaded a subset. Search, feedback, media and people
filters stay active when entering a folder, and the ids came from
`filteredPhotos` — so the button promised the folder and delivered whatever the
filter had left, or vanished when it matched nothing. Built from `scopedPhotos`.
Folder-only root misdetected. `rootIsFoldersOnly` tested `filteredPhotos`, so a
search matching none of the loose root photos looked folder-only and swallowed
the no-results message. Tests the unfiltered scope instead.
Empty state in the full-bleed layouts. The Premium/Story branch was missing the
folder-only guard the standard branch got, so a folder-only gallery printed
"no photos found" under its own folder chips.
Filter metadata still event-wide. `availableMediaTypes` and `colorLabelCounts`
counted over every photo, so the sidebar could offer a Video or colour chip for
something that only exists in another scope — always filtering to nothing. Both
derive from `scopedPhotos`, which moved above them for that reason.
* fix(gallery): honest folder downloads and scoped totals (#1160)
Silent truncation. /download-selected slices the id list to 500 server-side
(gallery.js:1776), so a folder larger than that delivered a truncated archive
under a button promising the whole thing. The limit is now mirrored client-side:
the request carries only what the server will honour and the label says
"Download first 500 of 620" instead of claiming the folder.
Gallery shell was being unmounted. Suppressing the folder-only empty state by
skipping PhotoGridWithLayouts took the hero, event title, logout and download
controls with it in the full-bleed layouts, since those render from inside that
component — a folder-only Premium gallery collapsed to a bare chip row. Replaced
with a suppressEmptyState prop so only the message goes.
Two more counts that could contradict the grid: the sidebar's total and the
people match-count denominator ("42 of 62" at a root that holds 42). Both scoped.
The client-access visible/total stat is deliberately left event-wide — that one
is a photographer-facing statistic about the gallery, not a filter affordance.
Stale admin cache. EventDetailsPage caches the same category rows under
'admin-event-categories' and hands them to the Photos tab's move dialog, so
toggling a folder left that dialog labelling it a plain category until remount.
Both keys are invalidated now.
Not changed, after challenging the review: select-all in the full-bleed layouts
stays scoped to the displayed photos. Wiring it to the full event would select
photos that are not on screen, contradicting containment and reviving the stale
selection bug. The reviewer withdrew the finding on that basis. The residual UX
gap — no one-click "everything" in Premium/Story once folders exist — is real
and noted on the PR.
* feat(gallery): one-click download-everything in the full-bleed layouts (#1160)
Premium and Story have no header download button — their only gallery-wide
download is select-all followed by download-selected, and select-all is
correctly scoped to what is on screen. Once folders exist that left no single
way to get the whole gallery. The folder strip now carries an event-wide
"Download all photos" that hits /download-all (which has always included
foldered photos), shown at the root only, since inside a folder the breadcrumb
already offers that folder's download.
Also lands the capped folder label that was written but never actually applied
in the previous commit — the edit silently didn't match, so a 510-photo folder
still advertised "Download folder (510)" while the request was capped to 500.
Caught by building a real 510-photo folder rather than trusting the reasoning:
it now reads "Download first 500 of 510". A unit test pins the client constant
to the backend's cap so the two can't drift apart unnoticed.
* fix(gallery): remount layouts on folder change, and stop scoped counts leaking into event-wide controls (#1160)
Carousel crash. Layout state is only meaningful for the photo set it was built
against, but the layout instance was reused across a folder change. In carousel
mode an index valid at root (31 of 42) indexes past the end of a smaller folder,
and CarouselGalleryLayout does `photos[currentIndex]` unguarded. The grid is now
keyed by the open folder, so a scope change remounts: verified live, 31/42 at
root becomes 1/20 on entering the folder instead of dereferencing undefined.
The key also avoids driving one instance between the empty and non-empty render
paths, which matters because that component's `photos.length === 0` early return
sits ABOVE four useState calls — a pre-existing conditional-hook hazard this
feature would otherwise have made reachable.
Nested empty state. suppressEmptyState only silenced PhotoGridWithLayouts' own
early return; the Premium and Story layouts have their own noPhotosFound return,
so a folder-only root still printed "no photos" under the tiles proving
otherwise. The flag is forwarded to them.
Download All was labelled from the wrong number. The sidebar's total is now the
folder scope (correct for the category list), but the same value labelled and
disabled Download All — which fetches the event-wide archive. On a folder-only
root that showed 0 and refused a valid download. Split into a separate
downloadAllTotal.
Feedback chip counts. likeCount, favoriteCount and ratedCount still counted over
every event photo while clicking them filters the scope, so a chip could promise
matches from another folder and deliver none.
* fix(gallery): premium crash, story Download All, and empty-mount hazard (#1160)
ReferenceError blanking the Premium gallery — my own bug from the previous
commit. The suppressEmptyState prop landed on the nested PhotoCard instead of
GalleryPremiumLayout (both destructure `allowDownloads = true`, and the patch hit
the first one), so the layout's guard referenced an identifier that was not in
its scope. A folder-only Premium root threw instead of rendering. Now declared
and destructured on the layout, and exercised: 62 photos all foldered renders
the tile, the hero and the download button with no message and no throw.
Story's footer "Download All Photos" built its id list from the `photos` prop,
which is now the folder scope — so it silently omitted every foldered photo
while still calling itself Download All. Layouts now receive an event-wide
downloadAllIds and prefer it. Premium's equivalent control is a select-all, not
a download, and stays scoped by the same reasoning as before.
Empty-array mounts. Suppressing the empty state meant the layout got mounted
with photos=[], and CarouselGalleryLayout returns before four of its useState
calls — driving one instance between empty and non-empty changes its hook count
and React throws. Only the full-bleed layouts, which own the hero and logout
chrome, are now mounted empty; every other layout renders nothing instead.
* fix(gallery): keep the shell and drop dead controls on folder-only roots (#1160)
Skipping the empty layout took the hero and welcome message with it. The early
return sat above both, so a gallery whose photos all live in folders lost its
configured hero and welcome copy at the root and only regained them after
opening a folder. Only the layout child is skipped now; the surrounding shell
renders as it always did.
The filter bar was gated on the event-wide photo count, so a folder-only root
still rendered search, sort and the feedback chips with nothing in scope for
them to act on — the same empty filter row discussion #317 asked us to remove.
Gated on the current scope.
Story's download toast counted `photos` while the request now carries the
event-wide id list, so it could announce "Downloading 0 photos" and then fetch
the whole gallery. Counts the ids it actually sends.
* fix(gallery): clear the person filter on scope change, and fix two folder-only shell details (#1160)
A person selected in one scope can have no photos in the next. peopleInScope
drops them from the strip, so the filter stayed active with nothing left to
clear it — and the full-bleed layouts have no people UI at all, leaving a guest
staring at an empty grid with a reload as the only way out. Cleared on both
folder navigation and popstate, alongside the category selection and the photo
selection already reset there.
Story's hero announced "0 Photos" on a folder-only root, since it derives that
stat from the scope it renders and the scope is empty by definition there.
Falls back to the event-wide count.
Premium's integrated Download All is a select-all over the current scope, so on
a folder-only root it was a visible control that did nothing when clicked. It is
hidden while the scope is empty rather than left dead.
* fix(gallery): uncapped Story download, protected folder covers, scoped people order (#1160)
The event-wide id list I added for Story's "Download All Photos" made it worse,
not better: /download-selected caps at 500 ids server-side, so a gallery larger
than that silently shipped a partial archive under a button promising all of it.
Replaced with an onDownloadEverything callback that runs the whole-gallery
/download-all path, which has no cap. eventPhotoCount now carries the number
Story needs for its hero stat, so no id list crosses the boundary at all.
Folder covers bypassed image protection. A cover is a real gallery photo, but it
was rendered through AuthenticatedImage's defaults while every photo tile passes
the gallery's protection settings — so on a gallery configured for canvas
rendering or maximum protection, each cover was an ordinary blob-backed <img>.
The tiles now receive and apply the same props as the grid.
People kept /people's event-wide ordering after their counts were rescoped, so a
folder's most-photographed person could sort behind someone with a single match
— and PeopleStrip only shows the first twelve inline. Sorted by the recomputed
count, with a test.
* fix(gallery): folder covers honour maximum protection (#1160)
Maximum protection implies canvas rendering even when the independent
use_canvas_rendering toggle is off, which is its default — every other gallery
image path spells that out as `useCanvasRendering || protectionLevel ===
'maximum'` (PhotoGrid, PhotoLightbox, HeroHeader, JustifiedGalleryLayout). The
folder cover forwarded the raw toggle, so on a maximum-protection gallery with
the toggle untouched the cover fell back to a blob-backed <img>. Matches the
convention now.
* fix(gallery): don't let download-everything bypass a category opt-out, and keep folder links alive across renames (#1160)
The whole-gallery route serves a prebuilt zip containing EVERY event photo with
no per-category filter — gallery.js says so itself, next to
bumpEventDownloadCounts, as a known pre-existing gap. Wiring Story's footer to
that route therefore converted a path that DID enforce the #640 opt-out into one
that doesn't, and because the callback was supplied unconditionally it affected
Story galleries with no folders at all.
The same reasoning applies to the download-everything button this branch added
to the full-bleed folder strip: it routes there too, so on a gallery with a
restricted category it would have handed over exactly the photos the opt-out
withholds. Both are now withheld whenever any category opts out; those galleries
keep the per-folder download, which enforces it. Verified both ways — the
control disappears with a restricted category present and returns once the
restriction is lifted.
Folder links also survived a rename badly: the key embeds the slug for
readability, and renaming a category rewrites that slug, so a URL already sent
to a client stopped matching and silently opened the gallery root. Resolution
now keys on the trailing category id, which does not move.
* fix(gallery): make folder navigation clickable in the Story layout (#1160)
Story renders `.story-nav` as `position: fixed` across the top of the viewport
at z-index 50, and the folder strip sits in exactly that band — so the nav
swallowed every click on the chips and the breadcrumb. A Story gallery whose
photos all live in folders had no way to reach them at all. Confirmed with
elementFromPoint at the chip's centre returning NAV.story-nav; the strip now
carries its own stacking context above it and the same probe returns the chip.
Story's footer download could also be offered with nothing to send: on a
folder-only root of a gallery that has a category download opt-out, the parent
deliberately withholds the whole-gallery callback and the scope is empty, so the
button would have posted an empty id list and taken a 400. It is only rendered
when one of the two actually exists.
* fix(gallery): stop the Story folder strip from blocking the layout's own nav (#1160)
The previous commit raised the whole folder strip above `.story-nav` so the
chips could be clicked, and thereby traded the bug for its mirror image: the
strip is mostly empty space, so as a solid z-60 container it swallowed the
clicks for Story's own search, favourites and logout sitting underneath.
The container no longer takes hits at all; only the chips, breadcrumb and
download button opt back in. The download button also loses its ml-auto, since
being pushed to the right put it physically on top of the nav's controls rather
than merely above them in stacking order.
Verified by hit-testing all three at once — folder chip, download button, and
Story's nav control each resolve to themselves under elementFromPoint, so none
is covering another.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
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 — the same fence #1199 put on the
orientation backfill for the same reason — 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, on the card as well
as in the log.
The card shows the count only when it is non-zero, the same shape the
orientation job uses for staleTiers. The wording 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 matches the
staleTiers key (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.
Carousel paints its own markup instead of going through PhotoCard, so it
inherited none of the colour-label treatment — not other viewers' marks from
#1178 and not the viewer's own from #1044. A photo the client flagged green
looked identical to one nobody had touched, in a layout a photographer can
select like any other. It has been missing since the feature landed.
Rendered in both places the carousel paints a photo. The thumbnail strip is
the one that matters: it is the only place the layout shows more than one photo
at a time, so it is the only place a label can actually be scanned.
Two small additions to ColorLabelBadge, both defaulting to today's behaviour so
every existing layout renders byte-identically:
- `size="sm"` shrinks the dots for the strip's 80px tiles, where the grid-sized
20px dot plus three 10px ones covers most of the image.
- `position` is overridable because this layout has different corners free. Its
top-left carries the counter and category chips and its top-right the
play/fullscreen buttons, so the badge goes bottom-left on the main frame —
the only corner left — and top-left in the strip, where nothing competes.
This is the per-layout position override #1178's review said would start to pay
for itself the first time a layout genuinely needed a different corner.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The checkbox had no `checked`, no `onChange`, and no place in the login
request; `rememberMe` existed only as an i18n label. On the backend
establishAdminSession hardcoded `expiresIn: '24h'` and the cookie always got
DEFAULT_MAX_AGE_MS, so there was nothing to receive it anyway.
Wired end to end: state on the page, `remember_me` in the login body, and a
30-day JWT plus a matching 30-day cookie when it is set.
Opt-in on purpose. An absent or malformed value means "no", so a client that
never sends it keeps exactly the 24h session it always had, and a stolen cookie
is still worth a day by default.
The JWT and the cookie take their lifetime from the same flag. If they can
disagree the session either dies early (long cookie, short token) or outlives
what the user consented to, so the tests assert them against each other.
Review found the feature was non-functional as written, which is the important
part: sessionTimeoutMiddleware and isSessionExpired enforce
security_session_timeout_minutes — 60 minutes by default — against a session's
idle time regardless of how long its token lives, so a remembered admin was
logged out within the hour with a 30-day token sitting unused. rememberMe now
travels in the JWT payload and both checks exempt a remembered session from the
IDLE timeout. Not from expiry: the token still dies on its own 30-day exp, and
revocation, deactivation and password-change invalidation are untouched.
Also: /api/admin/auth/change-password reissued a hardcoded 24h token without
the flag, so a remembered admin dropped back to 24h the moment they changed
their password — which is mandatory for new and reset accounts. It now inherits
the choice from the session it replaces, carried on req.admin.rememberMe.
Through MFA the choice rides inside the signed mfa_pending token rather than
being resent, so the second leg cannot ask for longer than the first agreed to.
The tests drive POST /api/auth/admin/login and read the real Set-Cookie and
token rather than minting a local clone of the ternary they are meant to be
checking, boot one database per file before anything reads it, and generate
their credential per run so no literal that looks like a password lands in the
repository.
No visual change — the checkbox was uncontrolled, so it already toggled on
click; it just did nothing.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(images): backfill orientation for libraries that predate the fix (#1198)
#1194 corrected the generators and every ingest path, but did nothing for
photos already in the database. Those rows end up worse than untouched ones:
before the fix a rotated photo was CONSISTENTLY wrong — a sideways image in a
tile shaped to match — and afterwards the regenerated thumbnail is correct
while photos.width/height still describe the raw sensor order, so masonry and
justified size a portrait photo with a landscape ratio. The dimension repair
cannot reach them: it only selects rows with a NULL dimension, and an affected
row has both, just transposed.
Its own job rather than a mode of that one. They look alike but are not the
same operation: the repair FILLS missing values and touches nothing else,
while this RECOMPUTES and invalidates the derived data generated against the
old orientation. Sharing a lease would also mean one blocks the other.
A first attempt at this was reverted from #1194 after review found five
problems. All five are addressed here:
- Originals are read through resolvePhotoStorageKey + withLocalCopy +
withProcessableImage, so the job works on S3 installs and on RAW/DNG. The
dimension repair's direct fs read does neither, which stops being an edge
case in a job that walks the whole library.
- The canonical preview is cleared BEFORE faces are requeued.
ensurePreviewImage returns a cached preview whenever it is still a valid
image, and a pre-fix unrotated one is perfectly valid — so requeueing alone
made the rescan read unrotated pixels and scale those boxes by the corrected
dimensions, which is worse than leaving the data alone.
- Invalidation keys off the EXIF transform, not a dimension delta. Orientations
2, 3 and 4 move every pixel while leaving width and height unchanged, as does
5-8 on a square image; a delta check skips exactly those rows.
- Archived events are excluded — archiving deletes the originals and keeps the
rows, so every one of them would fail its read.
- The dimension write and the invalidation share a transaction. Split, a
failure between them leaves stale face data that no retry can fix, because
the retry computes "already correct".
Tier deletion stays outside the transaction on purpose: it touches storage, and
a failed object delete must not roll back a correct database write. A leftover
tier regenerates on next read; a rolled-back write is silent corruption.
* fix(images): invalidate every stale rendition, fence the writes, and give the job a button (#1198)
Three things from review, one of which mattered a lot.
The invalidation was too narrow. Clearing only preview_path fixed the face
data and left the gallery worse off: ensureThumbnail and ensureHeroImage
return their cached file whenever it is merely VALID, and a pre-fix sideways
thumbnail is perfectly valid — so a corrected row rendered the old sideways
image inside a newly-corrected portrait tile. All three canonical renditions
are cleared now, their stored objects deleted, and both responsive tier sets
with them.
The responsive tiers also needed handling rather than a hopeful catch. Their
helpers swallow delete errors, and ensurePreviewImageAtWidth treats
storage.stat(key) as a cache hit — so a tier that survived deletion keeps
serving unrotated forever and never regenerates. The keys are re-checked after
deletion and survivors are counted into the result, so a run that could not
clear them does not report itself as clean.
Writes are fenced on the identity that was measured, not just the id.
replacePhoto swaps a new file under an existing row and rewrites
path/filename, and it IS reachable — from the replace_by_name upload path in
adminPhotos.js. A replacement landing while this job read the old original
would otherwise have had the previous file's dimensions written over it and
its fresh renditions cleared.
And the job had no way to start it: the endpoint existed with no caller, so an
upgrade would have left every affected library untouched unless an operator
found the API themselves. It gets a Status card like its two neighbours, with
strings in en/de/fr/sl. No backlog counter, because unlike the other two it
cannot know how many rows need it without doing the work.
* fix(images): make the backfill idempotent, and stop it lying about what it did (#1198)
Six things from review round 2.
The job was not idempotent, and the way it failed was expensive. Its trigger is
the EXIF tag on the ORIGINAL, which correcting a photo never changes — so every
re-run threw away the renditions it had just regenerated and requeued every
completed face scan. On a face-enabled install, running it twice meant
re-detecting the whole library for nothing. Migration 191 adds
photos.orientation_checked_at, written in the same transaction as the work it
records, with `force` as the escape hatch for an interrupted run.
The candidate query selected preview_path but not thumbnail_path or hero_path,
which the deletion loop reads — so those two pointers were cleared in the
database while the objects stayed in storage, still reachable through
previously issued URLs.
watermark_path was missed entirely. gallery.js serves it ahead of the original
when branding watermarking is on, which makes it the most visible rendition of
the lot. (Its generator needed rotating too — that went into #1185, where the
other three live.)
storage.stat() RESOLVES with null for a missing key rather than rejecting, so
counting "the promise settled" marked every deleted — and every never-created —
tier as a survivor. A perfectly clean run told the operator to re-run. Now a
null means gone, and a rejection counts as stuck, since a storage error is not
proof the object went away.
Face data is invalidated whenever the stored dimensions change, not only when
the change came from rotation: boxes are scaled by photo.width at read time, so
any dimension change strands them.
And `corrected` now comes from the affected-row count. If the fence rejected the
write because the file was replaced mid-run, the photo was not corrected and
the run must not claim it was.
* fix(images): stop the backfill doing unnecessary work, and make its retry advice true (#1198)
Round 3, four points, all narrower than the last two rounds.
It re-processed photos that were already correct. A 5-8 rotation changes the
dimensions, so a tagged photo whose stored dimensions are ALREADY oriented must
have been ingested after #1185 — its renditions are fine and clearing them
deletes valid files and rescans a completed face detection for nothing. Those
are now skipped and simply marked. Orientations 2, 3 and 4 (and 5-8 on a square
image) leave the dimensions identical either way, so they carry no such
evidence and are still done once.
The retry advice was impossible to follow. When a responsive tier could not be
deleted the row was still marked, so the ordinary re-run the UI recommends
found nothing and the stale tier kept serving unrotated forever. The marker is
withheld when a tier survives, which is what makes that message honest.
Storage cleanup now only runs when a fenced write actually landed. If the file
was replaced mid-run every update matched zero rows, but the deletion went
ahead anyway and could destroy renditions belonging to the REPLACEMENT —
watermarks especially, which are keyed by photo id and alias straight onto the
new file.
And the full-photo ETag includes the backfill's timestamp. It was built from
the ORIGINAL's mtime plus the watermark settings hash, neither of which this
job touches — so a guest holding a pre-fix ETag would go on getting 304 and
their cached sideways image no matter how many times the backfill succeeded.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(images): respect EXIF orientation in thumbnails, heroes and previews (#1185)
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: resizeToBox calls probe.rotate() for
stills, which is why the same photo looked correct on download and rotated in
the gallery. All three generators now do the same, guarded to stills for the
reason resizeToBox already documents — .rotate() flattens a multi-frame source.
The reporter also spotted the half that compounds it: photos.width/height were
stored from sharp's metadata, which reports pixels as STORED, not as displayed.
For orientation 5-8 those are swapped, so a portrait photo landed in the
database as landscape and masonry/justified sized its tile with the wrong
aspect ratio on top of the image being unrotated. A shared orientedDimensions()
helper now does that conversion at all four capture sites — managed upload,
background processing, external import and the dimension repair — so the stored
numbers describe the rotated result the generators now produce.
Existing rows keep their pre-rotation dimensions until the photo is
reprocessed; the images themselves correct on the next thumbnail/preview
regeneration.
Tests fail on the unfixed generators — verified by reverting the rotate calls
and the swap, which fails 4 of the 7.
* fix(images): orient dimensions on every ingest path, and stop guarding rotate where it protects nothing (#1185)
Review found the first cut covered four of eight dimension-capture sites. The
filesystem watcher, the S3 auto-importer, the v1 upload API and replace-by-name
all still persisted raw metadata.width/height, so an orientation 5-8 photo
arriving that way got a correctly rotated thumbnail and a database row
describing it as landscape — the same aspect-ratio mismatch this PR set out to
remove, just on the paths I had not grepped. (I searched for `metadata.width`
and the v1 route aliases it to `meta`.)
The animated guard was also wrong in two of the three generators.
generateThumbnail and generateHeroImage never pass `animated: true`, so they
already flatten a multi-frame source to its first frame — skipping .rotate()
there protected an animation that was being discarded anyway, while leaving the
output in raw orientation against swapped stored dimensions. Both now rotate
unconditionally. generatePreviewImage keeps the guard, because it genuinely
does open animated sources as animated and .rotate() would flatten them.
That leaves one corner unsolved rather than papered over: a multi-frame source
that also carries an orientation tag keeps its raw orientation in the preview
while the thumbnail and stored dimensions describe the rotated one. GIF has no
EXIF and animated WebP effectively never sets it, so it is a real gap but not a
common one, and closing it means rotating frame by frame rather than quietly
dropping the animation. Documented at the guard.
* fix(images): add a recompute mode so existing libraries get corrected too (#1185)
The orientation fix only helped new photos. A row affected by the bug has BOTH
dimensions stored — just in the raw order — so the repair job's NULL filter
could never reach exactly the rows that needed it. Worse, once their thumbnails
regenerated rotated, those rows went from consistently-wrong (sideways image in
a matching tile) to inconsistent: correct image, wrong-shaped tile.
`recompute` widens the candidate set to every image row. Opt-in, because it
re-reads every original.
It also has to deal with the consequence for faces. Detection runs against the
preview and stores boxes in ORIGINAL pixel space, scaled by
`photo.width / previewMeta.width` (faceProcessor.js:220-224) — so a photo whose
stored dimensions change has face data recorded against a coordinate system
that no longer exists, and the overlays crop the wrong region. Photos whose
dimensions actually change are requeued for scanning; ones that were already
correct are not, or a routine repair would rescan the whole library. Rows with
face_status NULL are left alone so installs that never enabled the feature
don't start scanning because of a dimension repair.
Writing the test for that last rule caught a real bug in it: the candidate
query never selected photos.width/height, so `photo.width` was undefined and
every row compared as changed. Both columns are selected now.
* Revert "fix(images): add a recompute mode so existing libraries get corrected too (#1185)"
This reverts commit cb771d08.
Review round 3 found five problems, all of them in this addition rather than
in the orientation fix itself, and one of them an own-goal: requeueing face
scanning makes processPhotoFaces call ensurePreviewImage, which returns the
CACHED pre-fix preview when it is still a valid image — so the rescan reads
unrotated pixels and scales those boxes by the newly corrected dimensions.
That is worse than leaving the data alone.
The rest need work this PR should not be carrying: the dimension repair reads
originals through resolvePhotoFilePath and plain sharp, so it does nothing on
an S3 install and rejects RAW/DNG; recompute pulls archived rows whose
originals were deleted on archive; orientation 2, 3 and 4 change the pixels
without changing width or height, so a dimension-delta test never notices them;
and the dimension write and the face invalidation are not atomic, so a failure
between them leaves a row that no retry will ever requeue.
Split out so it can be designed and reviewed on its own. The orientation fix —
.rotate() in the three generators and orientedDimensions() at all eight ingest
sites — is unaffected and stays.
* fix(images): the watermarked rendition needs orienting too (#1185)
A fourth generator with the same bug, found while reviewing the backfill that
builds on this. watermarkService composites and re-encodes through its own
sharp pipeline with no .rotate(), and gallery.js serves photos.watermark_path
ahead of the original when branding watermarking is on — so on a watermarked
gallery the sideways image is precisely what a guest sees.
Two details this needed beyond the .rotate() itself:
metadata() is read from a separate, unrotated handle. .rotate() does not change
what metadata() reports — a 400x200 source tagged orientation 6 still reads
400x200 — and every use of those numbers here is positioning: watermark scale,
font size, composite extent. They have to be the DISPLAYED dimensions or the
mark is placed against the wrong axis, so they go through orientedDimensions.
The composite offsets are floored. getPositionCoordinates derives from the
SVG's estimated text extent and returns fractional pixels; sharp rejects a
non-integer offset and applyWatermark catches its own error and returns the
image unwatermarked. Landing on a whole pixel was luck, and changing the
dimensions it is computed from ran out of it — the test surfaced a real
"Expected integer for left but received 92.8".
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(admin): gate the dimension repair as system maintenance (#1181)
The endpoint's candidate query is unscoped, so it walks every event in the
install, reads every original off S3 or the NAS mount, and rewrites their
metadata. It required only photos.edit, which the built-in team_photographer
preset holds (175_granular_permissions_and_presets.js:106) — a role that
exists for a contributing second shooter, not for someone who should be able
to start a whole-library scan or touch another owner's events.
Now system.manage, whose own description is "run system maintenance actions",
with the status endpoint on system.view to match. Nobody who should have it
loses it: super_admin is granted every permission, solo_photographer is
'ALL', and migration 175 already projects every settings.edit holder forward
onto system.manage on upgrade.
The capture-date sweep next to it was gated this way in #1179; this brings its
older twin in line.
* fix(admin): gate the dimension status card on the permission the button needs (#1181)
Same mismatch as the capture-date card: system.view and system.manage are
independent grants and StatusTab renders its card and enabled button purely on
a successful status payload (StatusTab.tsx:558), so a system.view-only role got
a live Repair button whose every click 403s.
* fix(admin): stop the dimension status card polling a 403 (#1181)
With the endpoint correctly requiring system.manage, anyone who can open the
Status tab but lacks it would have had a 403 and a logged denial every ten
seconds for a panel they were never shown. The query is now gated on the same
permission the endpoint requires, so it never starts.
* fix(admin): gate the dimension card's render on the permission too (#1181)
TanStack keeps the cached status after `enabled` flips false, so checking only
the payload would still show the card — and an enabled Repair button whose POST
403s — to a lower-privileged admin logging in behind a system.manage user
inside the cache lifetime.
* fix(admin): name the dimension-card permission flag for the card it gates (#1181)
#1179 adds a second system.manage-gated card to this same component with the
same flag name. Two identical declarations merge WITHOUT a conflict and then
fail to compile — TS2451, cannot redeclare block-scoped variable — and since
each PR is green on its own, nothing catches it until main's build breaks.
Verified by trial-merging both into main: no conflict, two declarations, tsc
fails on both lines. Naming this one for the card it gates removes the trap;
once both have landed the two flags can collapse into one.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(gallery): show other guests' colour labels in the grid (#1178)
A colour set by one guest was visible to others in the lightbox and invisible
on the tile. The lightbox reads /photos/:id/feedback, which returns per-colour
tallies across everyone; the grid reads /photos, whose payload carried only
`my_color_label` — so PhotoCard could render nothing else. The feature simply
was not extended to the grid.
/photos now also returns `other_color_labels`: the DISTINCT colours other
viewers put on each photo, gated on show_feedback_to_guests like every other
aggregate. `my_color_label` stays ungated, because a viewer's own selection is
not shared data — that distinction is unchanged.
Distinct colours rather than counts, and capped at three dots: a tile has room
for a couple of marks, and "who marked this, and how many" is a question the
lightbox already answers properly. The viewer's own colour is excluded from
the dots so the badge and the dots never say the same thing twice, and they
sit in opposite corners so they do not read as one group. The inset ring stays
the viewer's own signal, which is what the badge was built for.
Not addressed: the same issue asks for an identity-less shared colour tag —
one tag per photo that any guest can overwrite. Neither existing identity mode
does that (`simple` scopes by device fingerprint, `guest` by guest_id), so it
is a third model touching the feedback schema, the per-guest caps, moderation
and the admin aggregates. That is a feature with its own design, not part of
this fix.
* fix(gallery): carry other guests' labels into the premium and story grids too (#1178)
PhotoCard was not the only place the badge renders. GalleryPremiumLayout and
StoryPhotoCard have their own copies, and both still passed only
my_color_label — so the fix would have covered the default grid and left the
two full-bleed layouts showing nothing, which is the same shape of gap the
original bug had.
Found by driving a real gallery rather than reading the diff: the masonry grid
rendered the dots correctly, and a grep for the remaining call sites turned up
these two.
* fix(gallery): keep the other-viewers colour dots out of the contested corner (#1178)
The dots were placed bottom-left, which is the busiest corner in every
layout: Timeline paints a timestamp chip there on every tile, and Grid,
Mosaic and Masonry a media-type badge. All of them render after the badge,
so the dots sat underneath them.
Moved into a single row in the corner the colour-label dot already owns,
next to the viewer's own mark. Nothing new is contested, and the grouping
reads better anyway — your mark and everyone else's are the same kind of
information.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(admin): make "Storage used" report storage used (#1164)
The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which has no relationship to the disk PicPeak runs on. In reference mode those
files are never copied and sit on the NAS; duplicate rows counted the same
file twice (#1162); and it ignored everything PicPeak genuinely does write
locally: thumbnails, previews, hero renditions, watermarks and the per-event
download cache. The reporter's tile read ~80 GB against 21 GB of real usage.
Worse than the label: the same number drove the storage 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. Symlinks are
not followed, so a link into the media mount cannot put the NAS back in the
total. Cached for 5 minutes, since the dashboard polls.
- the dashboard tile and /storage/info now report that, with the catalogued
figure kept and labelled as such next to it. A failed measurement reads as
"unavailable" rather than substituting a number that means something else.
On the local rig: 64.37 MB used against 15.75 MB catalogued, of which 27.9 MB
is watermarks and 6.8 MB is download cache — none of which the old figure
could see.
Not addressed here: `.download-cache/all.zip` still has no TTL or size cap. It
is now at least visible in the breakdown, which is what makes the case for
capping it.
* fix(admin): exclude the media share from local storage usage (#1164)
External review found the walk could reintroduce the exact over-count it
replaces.
EXTERNAL_MEDIA_ROOT's compose default is `<storage>/external-media`, where the
NAS is bind-mounted. That is a plain directory, not a symlink, so the symlink
guard did not cover it and the walk descended into the share — putting every
referenced original back into a figure whose whole purpose is to leave them
out, and comparing NAS bytes against statfs() of the local disk. On the
reference-mode installs this issue is about, that is the failure mode
reappearing inside its own fix.
The configured root is now skipped when it lies inside the storage root, and
the result reports which path was excluded. A directory that merely shares the
name is still counted, because those really are local bytes.
Also from the review:
- concurrent cold-cache callers now share one walk. /dashboard/stats,
/storage/info and the sidebar are routinely requested together, and each was
starting its own stat-per-file traversal of the whole library.
- storage_partial is surfaced in the StorageInfo type and the sidebar tile, not
just the dashboard and analytics cards. An unreadable subtree makes the total
a floor, and a floor silently compared against a soft limit reads as "safely
under".
* fix(admin): do not report a disk walk on an S3 backend (#1164)
Second review round.
S3 installs were regressed. With STORAGE_BACKEND=s3 the originals, renditions,
archives and download caches are objects in the bucket and STORAGE_PATH holds
only incidental local files — so the walk reported near-zero and the soft-limit
recommendation was derived from it. Those installs now keep the catalogued
figure, which is the approximation they had before this PR, and the response
says which measurement it is (`storage_measurement: 'disk' | 'catalog'`) so the
UI labels it instead of implying a disk measurement that never happened.
The Settings → Status storage card ignored storage_partial, formatting a lower
bound as exact and deriving the limit percentage from it — so an unreadable
subtree could read as safely under the limit. It now carries the same `+`
marker as the sidebar and dashboard.
* fix(admin): stop rendering an absent measurement as zero usage (#1164)
Third review round, two findings.
The analytics storage bar coerced a null measurement to 0, drawing an empty
bar labelled "0% of limit" and suppressing the over-limit state — reading as
plenty of room at exactly the moment nothing is known. It now shows the
catalogued figure on S3, where that IS the available answer, and says "no
measurement available" rather than inventing a percentage when there is none.
/storage/info walked the filesystem before checking the backend and then threw
the result away on S3. The sidebar polls that endpoint, so a migrated install
still holding a large local tree paid a full stat-per-file traversal on every
cold cache for nothing. Gated before the walk, as the dashboard route already
was.
* fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164)
External review of the stable twin.
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%.
Also lands the AnalyticsPage half of the previous round, which the commit
message claimed but the commit did not contain — only its backend counterpart
was staged. The stable twin has carried it since it was written, so this is the
parity gap in the unusual direction.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)
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 — the slideshow never
had a fallback worth taking. Preferring it fixes every existing install with no
migration and no admin action, and `url` still backstops videos, where both
derivative URLs are null.
Verified on the local rig with the toggle off, so the photos API returns
preview_url: null exactly as filed. Opening one photo:
before GET /photo/82, /photo/81, /photo/21 (3 originals)
after GET /preview/82?w=1280, /preview/81, /preview/21
397 KB -> 23 KB per image on that gallery's test photos.
The toggle no longer decides whether the lightbox uses previews, so its copy
said something untrue; it now describes what it still does, which is
pre-generate rather than wait for the first guest to open a photo. Updated in
en/de/fr/sl, the locales that carry those keys.
* fix(gallery): cover the layouts the lightbox fix missed (#1166)
External review found the fix was incomplete, and the review of it found one
more.
Premium galleries were untouched. PhotoGridWithLayouts returns early for
gallery-premium, which builds its own yet-another-react-lightbox slides with
`src: photo.url` — so those galleries kept pulling full originals and the
reported bandwidth problem remained. They now use lightboxImageUrl for the
display source; `download` deliberately stays on photo.url, because what a
guest saves must be the original.
The Story layout was worse, and neither the issue nor the review caught it:
StoryPhotoCard rendered the full original as its GRID TILE, at object-cover in
a small card. That is the one place where "hundreds of megabytes for a gallery"
was literally true. It now uses the per-device thumbnail tier like PhotoCard,
and its PhotoSwipe source uses the preview tier.
Animated GIFs keep the original. generatePreviewImage always encodes JPEG, so
routing an animated source through the preview tier would have replaced the
animation with its first frame — a regression the toggle-off default never
had. Animated WebP has the same problem and cannot be distinguished by MIME
alone; that needs the backend to report it (Sharp's `metadata.pages > 1`) and
is left rather than costing every static-WebP gallery the bandwidth fix.
The settings copy claimed too much. "Pre-generate lightbox previews" does not
generate anything on save — it unlocks the regenerate button and keeps
preview_url emitted. Reworded to say that, in en/de/fr/sl.
Not changed: the review's P1 said this bypassed the secure-image route on
enhanced/maximum galleries. It does not. AuthenticatedImage collects
requiresToken and secureUrlTemplate into an explicitly-voided unusedProps and
never substitutes {{token}}, so on those protection levels photo.url was a
literal `.../secure/82/{{token}}` that returns 400 — the lightbox was falling
back to the 300px thumbnail, not to a protected image. Verified against a live
maximum-protection gallery. Codex withdrew the finding on that evidence.
* fix(gallery): keep premium downloads working and story framing intact (#1166)
Second review round, three findings — two of them regressions this PR
introduced.
Premium Download became a no-op. handleDownloadFromLightbox recovered the
photo with `filteredPhotos.find(p => p.url === slide.src)`, and slide.src is a
derivative now, so the lookup found nothing and the button silently did
nothing. The slide carries the photo id and the handler resolves by that;
what Download hands over is still the original.
Story cards were reframed. thumbnail_fit is seeded to 'cover' on every
install, so thumbnails are square centre-crops — and story cards are not
square (400x500 in the carousel, fixed-height in the desktop grid), so the
card's own object-cover cropped them a second time and every photo shifted.
They now use the preview tier, which is fit:'inside' and therefore the whole
frame: the card looks exactly as it did before, without pulling an original.
APNG joins the animated-format guard. It declares image/apng and the preview
route would serve a static frame. Animated WebP still cannot be detected from
MIME and remains the documented gap.
* fix(gallery): keep PNG on the original, alpha and all (#1166)
Third review round.
generatePreviewImage encodes JPEG, which drops ALPHA as well as animation — a
transparent PNG came back flattened against a solid background. And an APNG is
normally reported as image/png, so the image/apng check alone missed the
common upload path. PNG now stays on the original: it is where transparency is
the norm, and rare enough in an event gallery that the bandwidth given up is
small.
Animated or alpha WebP still cannot be detected from MIME and remains the
documented gap; it needs the backend to report Sharp's `pages`/`hasAlpha`.
Two further findings are acknowledged and deferred rather than fixed here:
- Story cards now request /preview on mount, so a cold gallery generates its
previews in one burst. That is a new CPU cost, not a regression — those cards
previously fetched full ORIGINALS on mount, which is strictly worse. Doing it
properly means viewport-gating AuthenticatedImage, which is a change to a
component every gallery surface uses and belongs in its own PR.
- The premium layout memoizes slide URLs, so rotating the device before opening
the lightbox can leave a photo on the tier chosen for the old geometry. The
result is a slightly undersized image, and the fix is a resize subscription
this PR does not otherwise need.
* fix(gallery): load Story images on approach, and give the hero its own tier (#1166)
Every card in a Story gallery mounts at page load — `whileInView` gates the
animation, not the render — and AuthenticatedImage fetches from an effect on
mount, so all of them requested at once. That was tolerable while they pointed
at photo.url, because nothing was generated; pointing them at the preview tier
meant a gallery with cold previews would Sharp-decode every original in one
burst. The image now waits until the card is within 200px of the viewport,
using framer-motion's useInView — the same observer the entrance animation
already relies on — with `once` so a card never unloads on scroll-away.
Verified on a 62-photo Story gallery: 3 images fetched at load, growing to 15
as you scroll, where all 62 would have fired before.
While confirming that, the hero turned out to be doing the same thing the
cards were. StoryHero rendered photo.url as a full-bleed object-cover
background — a full original on the critical path for first paint of every
Story gallery — when hero_url exists for exactly this and is a 1920x1080 cover
crop emitted unconditionally for every photo (gallery.js:1139).
That gallery now issues no /photo/ request at all: hero_url for the hero,
the preview tier for the cards, and only as they come into range.
* fix(previews): preserve alpha and animation in the preview tier
Follow-up to #1166, which had to bypass the preview tier for GIF, APNG and PNG
to avoid a visible regression. This removes the cause.
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: the slideshow (#1015), admin
previews, and the face avatars that read it as a whole-frame rendition. 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 common path pays nothing.
Two things had to move with it:
- The output extension now 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, so it labels itself explicitly;
preserving animation through the watermark compositor is a separate problem.
The frontend guess-by-MIME goes away entirely — including the case it could
never get right, since a still and an animated WebP declare the same type.
Verified on the local rig: a transparent PNG round-trips as
`Content-Type: image/webp`, `hasAlpha: true`, 8.3 KB; an ordinary photo still
serves `image/jpeg` from a `.jpg` key.
* fix(previews): retire the legacy preview keys, and stop mislabelling watermarked ones
External review of the stable twin found two defects, both on this branch too.
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. My PR body claimed "pre-existing
keys have no .webp suffix and are JPEG" — that 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 188 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, so the cost is one regeneration per photo actually
viewed. Storage is untouched, as elsewhere.
The watermark branch mislabelled its output. applyWatermark PRESERVES the
source format (watermarkService.js:200-211: 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. The override is
gone; the animation loss through the compositor is documented where it
happens.
* fix(gallery): make the Story hero fix actually work on external galleries (#1166)
External review of the stable twin, both applying here too.
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. ensureHeroImage now has the same external branch
ensurePreviewImage does — direct fs read, per-photo output basename — and
returns null instead of throwing for a reference-mode row with no
source_origin.
The format bypass trusted mime_type, which is not trustworthy here. 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.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)
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 — the slideshow never
had a fallback worth taking. Preferring it fixes every existing install with no
migration and no admin action, and `url` still backstops videos, where both
derivative URLs are null.
Verified on the local rig with the toggle off, so the photos API returns
preview_url: null exactly as filed. Opening one photo:
before GET /photo/82, /photo/81, /photo/21 (3 originals)
after GET /preview/82?w=1280, /preview/81, /preview/21
397 KB -> 23 KB per image on that gallery's test photos.
The toggle no longer decides whether the lightbox uses previews, so its copy
said something untrue; it now describes what it still does, which is
pre-generate rather than wait for the first guest to open a photo. Updated in
en/de/fr/sl, the locales that carry those keys.
* fix(gallery): cover the layouts the lightbox fix missed (#1166)
External review found the fix was incomplete, and the review of it found one
more.
Premium galleries were untouched. PhotoGridWithLayouts returns early for
gallery-premium, which builds its own yet-another-react-lightbox slides with
`src: photo.url` — so those galleries kept pulling full originals and the
reported bandwidth problem remained. They now use lightboxImageUrl for the
display source; `download` deliberately stays on photo.url, because what a
guest saves must be the original.
The Story layout was worse, and neither the issue nor the review caught it:
StoryPhotoCard rendered the full original as its GRID TILE, at object-cover in
a small card. That is the one place where "hundreds of megabytes for a gallery"
was literally true. It now uses the per-device thumbnail tier like PhotoCard,
and its PhotoSwipe source uses the preview tier.
Animated GIFs keep the original. generatePreviewImage always encodes JPEG, so
routing an animated source through the preview tier would have replaced the
animation with its first frame — a regression the toggle-off default never
had. Animated WebP has the same problem and cannot be distinguished by MIME
alone; that needs the backend to report it (Sharp's `metadata.pages > 1`) and
is left rather than costing every static-WebP gallery the bandwidth fix.
The settings copy claimed too much. "Pre-generate lightbox previews" does not
generate anything on save — it unlocks the regenerate button and keeps
preview_url emitted. Reworded to say that, in en/de/fr/sl.
Not changed: the review's P1 said this bypassed the secure-image route on
enhanced/maximum galleries. It does not. AuthenticatedImage collects
requiresToken and secureUrlTemplate into an explicitly-voided unusedProps and
never substitutes {{token}}, so on those protection levels photo.url was a
literal `.../secure/82/{{token}}` that returns 400 — the lightbox was falling
back to the 300px thumbnail, not to a protected image. Verified against a live
maximum-protection gallery. Codex withdrew the finding on that evidence.
* fix(gallery): keep premium downloads working and story framing intact (#1166)
Second review round, three findings — two of them regressions this PR
introduced.
Premium Download became a no-op. handleDownloadFromLightbox recovered the
photo with `filteredPhotos.find(p => p.url === slide.src)`, and slide.src is a
derivative now, so the lookup found nothing and the button silently did
nothing. The slide carries the photo id and the handler resolves by that;
what Download hands over is still the original.
Story cards were reframed. thumbnail_fit is seeded to 'cover' on every
install, so thumbnails are square centre-crops — and story cards are not
square (400x500 in the carousel, fixed-height in the desktop grid), so the
card's own object-cover cropped them a second time and every photo shifted.
They now use the preview tier, which is fit:'inside' and therefore the whole
frame: the card looks exactly as it did before, without pulling an original.
APNG joins the animated-format guard. It declares image/apng and the preview
route would serve a static frame. Animated WebP still cannot be detected from
MIME and remains the documented gap.
* fix(gallery): keep PNG on the original, alpha and all (#1166)
Third review round.
generatePreviewImage encodes JPEG, which drops ALPHA as well as animation — a
transparent PNG came back flattened against a solid background. And an APNG is
normally reported as image/png, so the image/apng check alone missed the
common upload path. PNG now stays on the original: it is where transparency is
the norm, and rare enough in an event gallery that the bandwidth given up is
small.
Animated or alpha WebP still cannot be detected from MIME and remains the
documented gap; it needs the backend to report Sharp's `pages`/`hasAlpha`.
Two further findings are acknowledged and deferred rather than fixed here:
- Story cards now request /preview on mount, so a cold gallery generates its
previews in one burst. That is a new CPU cost, not a regression — those cards
previously fetched full ORIGINALS on mount, which is strictly worse. Doing it
properly means viewport-gating AuthenticatedImage, which is a change to a
component every gallery surface uses and belongs in its own PR.
- The premium layout memoizes slide URLs, so rotating the device before opening
the lightbox can leave a photo on the tier chosen for the old geometry. The
result is a slightly undersized image, and the fix is a resize subscription
this PR does not otherwise need.
* fix(gallery): load Story images on approach, and give the hero its own tier (#1166)
Every card in a Story gallery mounts at page load — `whileInView` gates the
animation, not the render — and AuthenticatedImage fetches from an effect on
mount, so all of them requested at once. That was tolerable while they pointed
at photo.url, because nothing was generated; pointing them at the preview tier
meant a gallery with cold previews would Sharp-decode every original in one
burst. The image now waits until the card is within 200px of the viewport,
using framer-motion's useInView — the same observer the entrance animation
already relies on — with `once` so a card never unloads on scroll-away.
Verified on a 62-photo Story gallery: 3 images fetched at load, growing to 15
as you scroll, where all 62 would have fired before.
While confirming that, the hero turned out to be doing the same thing the
cards were. StoryHero rendered photo.url as a full-bleed object-cover
background — a full original on the critical path for first paint of every
Story gallery — when hero_url exists for exactly this and is a 1920x1080 cover
crop emitted unconditionally for every photo (gallery.js:1139).
That gallery now issues no /photo/ request at all: hero_url for the hero,
the preview tier for the cards, and only as they come into range.
* fix(gallery): make the Story hero fix actually work on external galleries (#1166)
External review of the stable twin, both applying here too.
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. ensureHeroImage now has the same external branch
ensurePreviewImage does — direct fs read, per-photo output basename — and
returns null instead of throwing for a reference-mode row with no
source_origin.
The format bypass trusted mime_type, which is not trustworthy here. 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)
external_relpath has been resolved from EXTERNAL_MEDIA_ROOT rather than from
event.external_path since #1163 landed. This fixture still carried the
base-relative form — its own comment noted the change was 'a separate stack' —
so the two tests stopped resolving and ensureHeroImage returned null the moment
that stack merged. The production path was never affected.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Both photo sweeps tracked whether they were running in a module-level variable.
Correct on one replica, wrong behind a load balancer: the status poll answers
from whichever process it reaches, so an idle replica reports isRunning false
while another is mid-run, the UI re-enables the button, and the next POST lands
elsewhere and starts a second pass over the whole library. The .whereNull()
guards mean nothing is corrupted; the cost is duplicated S3/NAS I/O and an
operator who cannot tell whether a job is running.
Migration 189 adds one row per job. The claim is a conditional UPDATE whose
affected-row count is the answer — the shape backgroundProcessor already uses
to hand a photo to exactly one worker — so two replicas cannot both match.
The lease is fenced on a per-claim token: taking over a stale claim does not
stop the old runner, so without fencing a superseded runner finishing late
cleared the new owner's flag and overwrote its result. heartbeat() reports
renewal failure and the loops stop on it. Renewal runs on a timer spanning the
claim through release, including the candidate query, because one hung NAS read
can outlast the stale window inside a single iteration.
maintenance_jobs is excluded from .picpeak archives — an archive taken mid-sweep
would otherwise restore a live lease with no runner to release it. The importer
filters the same set, so older archives are skipped too.
Response shape is unchanged, so the frontend needs no change.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(external-media): record capture dates on import, and backfill existing libraries (#1172)
External imports never read EXIF, so photos.captured_at stayed NULL for every
row they created. The gallery sorts "Date Taken" with
COALESCE(captured_at, uploaded_at), which on a bulk import is the import
timestamp — so the sort silently degraded into "order by import batch" with no
error and nothing in the UI to say the sort key was missing. The reporter's
12-day trip came back with its first two days at positions 4204-5296 of 5555,
because those folders happened to be imported second.
- the import reads the capture date next to the sharp().metadata() call that
already opens the file, so this costs one more read of the same source rather
than a second pass over the mount. Best-effort like the dimensions: a source
without EXIF imports with captured_at NULL, as before.
- POST /api/admin/photos/repair-capture-dates backfills existing libraries,
modelled on the dimension repair beside it — background pass, in-flight
guard, status endpoint, and resolvePhotoFilePath, which is what reaches an
external row at all. Not a migration: the originals sit on a mount that may
be down at upgrade time, reading 8000+ of them would block the boot, and a
run that found nothing has to be repeatable.
- "no EXIF date" is counted separately from "could not read the file". An
operator needs to tell "these files carry no date" from "the mount is
broken" before deciding to re-run.
- the update is guarded whereNull, so an import finishing mid-run is not
overwritten by a slower pass.
- every sort branch now carries photos.id as a tiebreaker, not just
capture_date. A bulk import writes hundreds of rows inside one second, so
uploaded_at and the COALESCE fallback both collapse and the grid reshuffles
between loads. id is insertion order, which makes the fallback meaningful.
Not addressed: extractCaptureDate reads no OffsetTimeOriginal, and exifr
resolves a naive EXIF timestamp against the HOST timezone — so captured_at is
not a true instant, and the same file imported on two machines yields two
values. That predates this and applies to managed uploads equally; the tests
here deliberately assert ordering rather than an absolute instant so they do
not encode the bug. Worth its own issue.
* fix(capture-dates): read managed originals through storage, skip archived, claim the run flag (#1172)
Four holes in the backfill endpoint, all found in review:
- Managed photos were resolved with resolvePhotoFilePath, which builds a
STORAGE_PATH filesystem path. On an S3 install nothing is there, so every
managed row failed. Now split the way the thumbnail regenerator does:
external rows read from the mount directly, managed rows go through
resolvePhotoStorageKey + withLocalCopy.
- Archived events keep their photos rows but their originals are deleted on
archive, so those rows failed every run and kept the button lit forever.
Excluded from both the job and the status counts.
- isRunning was claimed after the candidate query, so two concurrent POSTs
could both pass the guard and start a pass. Claimed before the await, with
every early exit releasing it.
- The noExif comment promised a distinction extractCaptureDate does not make
(it returns null for unreadable files too). Reworded to what it is.
* chore: drop a stray node_modules symlink committed by mistake
The .gitignore pattern is `node_modules/`, which matches a directory and
not a symlink of the same name, so a local convenience link slipped past it.
It pointed at an absolute path on one machine and would dangle everywhere
else, breaking `cd backend && npm install`.
* fix(capture-dates): gate the backfill as system maintenance, stop overstating the counters (#1172)
The endpoint walks every event in the install and rewrites their metadata,
but required only photos.edit — which the built-in team_photographer preset
holds (175_granular_permissions_and_presets.js:106). That role exists for a
contributing shooter, who should not be able to start a whole-library S3/NAS
scan or touch another owner's photos. Now system.manage, with the status
endpoint on system.view so the panel simply stays hidden for everyone else.
The "without EXIF date" wording also promised a distinction the code does not
draw: extractCaptureDate returns null for an unparseable file as well as for
one that genuinely carries no date, so both land in that bucket. Reworded to
"no date found" / "unreachable" in en, de and fr, which is what the two
numbers actually separate.
* docs: point the permission note at the follow-up PR (#1172)
The dimension repair's matching gate landed in #1182, so the comment no
longer needs to describe it as unaddressed.
* fix(i18n): align the Slovenian capture-date wording with the other locales (#1172)
sl was missed when the counters were reworded from 'without EXIF date' /
'unreadable' to what they actually measure.
* fix(capture-dates): gate the status card on the permission the button needs (#1172)
system.view and system.manage are independent grants, and StatusTab has no
permission gate of its own — a successful status payload is what renders the
card and its enabled button (StatusTab.tsx:637). Gating the status endpoint on
system.view therefore handed a system.view-only role a live Backfill button
whose every click 403s, with no error surfaced by the mutation.
The comment above it already claimed this endpoint matched the POST. Now it
does.
* fix(gallery): make the Date Taken sort correct on SQLite (#1172)
photos.captured_at does not hold one type on SQLite. Three writers put three
different things in it:
integer managed uploads — photoProcessor.js:488 hands knex a Date, which the
sqlite3 binding stores as epoch milliseconds
text external imports and the backfill, which write ISO-8601
null no capture date, so the sort falls through to uploaded_at, itself
text in knex's 'YYYY-MM-DD HH:MM:SS' default shape
A plain COALESCE over that is not an ordering. SQLite sorts INTEGER before TEXT
unconditionally, so every managed photo carrying EXIF came back ahead of every
photo that did not, whatever the dates said — a 2027 capture landing before a
2020 one. Among the text values 'T' (0x54) also outranks the space (0x20), so a
same-day ISO 01:15 sorted behind a fallback 23:00.
Both failures predate this branch — the first needs only two managed photos —
but making that sort correct is what #1172 is about, so it is fixed here rather
than left for the issue it belongs to.
Normalised in the ORDER BY rather than by rewriting the column: the data fix
would have to touch every existing row and every writer, which is a far heavier
change than the sort it corrects. The cost is that this sort no longer uses
idx_photos_captured_at on SQLite — an acceptable trade on the fallback engine,
where the alternative is an index-assisted wrong answer. Postgres is untouched:
captured_at is a real timestamp there and COALESCE already compares correctly.
The regression tests drive the real gallery route on real SQLite. They write
the epoch-millisecond integer directly, because the Date that produces it in
production cannot be reproduced inside jest — there the binding's type dispatch
misses sandbox Dates and stores "[object Object]" (CLAUDE.md). All four
behavioural tests fail on the unfixed ORDER BY; verified by reverting it.
* fix(gallery): normalise epoch-integer uploaded_at too, and stop polling a 403 (#1172)
Two follow-ups from review.
uploaded_at is not always text on SQLite either. A legacy archive restore
leaves epoch milliseconds in it — there is a test pinning exactly that
(__tests__/integration/sqliteEpochTimestamps.test.js) — and the fallback branch
read it with substr(), so '1830297600000' was compared against
'2020-01-01 00:00:00' as text and a 2028 upload sorted first. Both columns now
get the integer/real branch.
The status card also polled every ten seconds regardless of permission. With
the endpoint correctly requiring system.manage, anyone who can open the Status
tab but cannot run the job would have had a 403 and a logged denial every ten
seconds for a panel they were never shown. The query is now gated on the same
permission the endpoint requires, so it never starts.
* style: quote convention in the capture-sort test (#1172)
* fix(capture-dates): skip watcher-imported videos, and make the status counts consistent (#1172)
Three follow-ups from review.
fileWatcher.processNewPhoto sets type='video' and a video/* mime but never
media_type (fileWatcher.js:128-130), so those rows keep the 'image' default
from migration 048. Filtering on media_type alone queued every such video on
every run — extractCaptureDate returns null for a video, captured_at stays
null, and the backlog never cleared. Candidate query and status scope now check
all three markers.
The status counts were two separate queries, so an import committing a dated
photo between them could be counted by the second and not the first: the card
then showed withCaptureDate > total and a negative backlog, with the button
enabled to "fix" it. One aggregate now.
And the card's render checked only the cached payload. TanStack keeps that
after `enabled` flips false, so a lower-privileged admin logging in behind a
system.manage user inside the cache lifetime would still have seen the card and
a button whose POST 403s. The permission is part of the render condition now.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(external-media): store external paths from the media root (#1163)
Importing a second folder into an event silently invalidated every photo
already in it. photos.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 and their originals resolved to paths
that do not exist.
Nothing errored, and the grid still looked intact: thumbnails are written to
local storage during the import while the base path is still correct. Only
what needs the original broke — preview generation, the lightbox, downloads —
which presents as a gallery that looks slow rather than one that is broken.
The reporter had 7547 of 8004 rows pointing into the void and spent a while
chasing it as a CPU problem.
- external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is
self-describing and nothing an admin does to the event afterwards can move
an already-imported photo.
- migration 187 folds each event's base path into its rows. Where the current
resolution is missing on disk it walks up the base path for an ancestor
under which the file IS there — the already-rebased case — and where it
finds nothing it leaves the row resolving exactly where it resolves today.
Skipped entirely when the media root is unmounted, since every file looks
missing then.
- the fold also runs after a .picpeak restore: knex_migrations is excluded
from the archive, so a pre-#1163 backup would otherwise land base-relative
rows on a migrated instance.
- drops the duplicate-leaf-segment guess in photoResolver. It papered over
this same double-prefixing and actively corrupts a root-relative path whose
first segment legitimately repeats (base 'Trip', row 'Trip/x.jpg').
* fix(external-media): verify provenance and fold atomically (#1163)
External review found four real defects in the fold.
Repair could adopt the wrong file. Existence alone was accepted as proof that
an ancestor candidate was the row's original — so a row whose file an admin
simply deleted would adopt any same-named file one directory up (base
`Trip/Sub`, relpath `photo.jpg`, an unrelated `Trip/photo.jpg`), and downloads
would then serve a different photo. Worse than a dead link. An ancestor must
now also match photos.size_bytes, which the import recorded from the very file
the row describes; rows carrying no size are never repaired from an ancestor.
The CURRENT base is still accepted on existence alone, because nothing is
being inferred there — that is where the row already resolves.
The fold was not atomic. Every UPDATE committed independently and the marker
came last, so a process killed mid-fold left converted and unconverted rows
with no marker — and the next run folded the converted ones a second time,
putting every original one directory deeper with no undo. Probing is now a
read-only first phase (so a slow cold NAS does not hold a write transaction
open), and every rewrite plus the marker commit together.
Failed rewrites certified a partial conversion. The per-row catch counted any
error as a collision, carried on, and wrote the marker anyway — leaving that
row in the old format for a resolver that now reads it differently. It also
could not tell a genuine duplicate from a SQLite lock or I/O fault. Target
collisions are now resolved in the planning phase, where they can be
identified honestly, and a write that fails rolls the whole fold back.
Restore ordering. The fold ran after the face requeue, with the worker live —
so a worker could claim an external row while it was still base-relative,
resolve it against the wrong path, and burn it to 'failed', a state only an
explicit Re-scan clears. The fold now runs first, for the same reason the
requeue already sat after restoreFiles.
* fix(external-media): close the fold's remaining stranding paths (#1163)
Second review round, three findings.
A collision loser was left stranded. When an event imported one file through
both `Trip` and `Trip/Sub`, two rows folded to the same path and the loser was
skipped — keeping a base-relative value that the root-only resolver then reads
as `<root>/<relpath>`, permanently wrong, with the marker claiming conversion
was complete. It is a duplicate by construction, so it now goes through
migration 186's deleteDuplicatePhotos, which reparents its feedback and marks
and reconciles the face clusters instead of orphaning them. This branch is
rebased onto #1162 for that helper.
The other restore path had the same face-ordering bug. restoreService queued
face scans in step 6, before step 7c runs pending migrations — so a pre-187
full or database restore handed the live worker rows whose paths were still
event-relative, and it burned them to 'failed', a state the later fold does
not clear. The requeue now happens after the migrations, where the files
already are.
A failed conversion was reported as a clean restore. The fold is
transactional, so a failure leaves every external path in the old format under
a resolver that reads from the media root — every original unreachable. It was
logged as a warning and the restore returned success. It now returns
externalPathsConverted/externalPathError, and suppresses the face requeue,
which would otherwise mark those photos failed on top.
* fix(external-media): make the fold safe against its own intermediate states (#1163)
Third review round, four findings.
A one-pass rewrite could collide with itself. Every FINAL path is distinct,
but a final value can equal another row's CURRENT one — `photo.jpg` repairing
to `Trip/photo.jpg` while the row already holding `Trip/photo.jpg` folds
deeper — so the update violated migration 186's unique index halfway through.
On Postgres that surfaces as 23505, which run-migrations-safe.js mistakes for
"schema already exists" and records 187 as applied after the rollback, leaving
every path unconverted with nothing to retry. Rows now park on a per-row
staging value first, and migration 187 re-throws without the driver's code so
the runner cannot misread it.
The bulk update targeted rows the plan never saw. Phase 1 probes outside the
transaction and can run for minutes; an import finishing in that window
inserts an already root-relative row, and `where event_id` prefixed it again
with the stale base. It now updates by the ids phase 1 captured.
The restore UI never showed a conversion failure. The API carried
externalPathsConverted, but PicpeakBackupCard neither declared nor read it and
showed a green success either way — so an admin whose external originals were
all unreachable was told the restore worked.
restoreService requeued faces even when the migrations failed. The step 7c
catch is deliberately non-fatal, so a pre-187 backup whose fold never ran
still handed the live worker event-relative paths to burn to 'failed'.
* fix(external-media): the fold's staging value must be storable on Postgres (#1163)
External review of the stable twin caught this, and it was on both branches.
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 187 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. It still cannot collide with a real relative
path and is still obviously wrong if a crash leaves one behind.
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>
* fix(external-media): one row per external file per event (#1162)
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 186 removes the duplicates that already exist and adds a partial
unique index on (event_id, external_relpath). The survivor is the lowest id
that has a thumbnail, so a half-finished import does not cost a grid tile,
and hero references are repointed first because the FK is SET NULL.
- the route treats a unique violation as a skip and carries on, so a second
writer this process cannot see (another replica) converges instead of
duplicating or 500ing.
- a second import while one is already running now gets a 409 rather than
walking the whole tree to have every insert bounce.
The duplicates' thumbnail files are left behind as unreferenced bytes — a
migration is the wrong place to reach into storage, which may be S3.
* fix(external-media): keep dependent rows and legacy restores intact (#1162)
External review found two real defects in the dedupe half of this fix.
Dangling rows on SQLite. Every FK into photos declares ON DELETE CASCADE, but
PicPeak never sets `PRAGMA foreign_keys = ON` — the codebase says so where it
deletes an event (adminEvents/helpers.js:245) — so on every SQLite install the
cascade is inert and deleting a duplicate photo left its face embeddings,
guest feedback and admin marks behind, pointing at an id that no longer
exists. Biometric data outliving its photo is exactly the invariant the event
delete goes out of its way to hold.
Dependents are now handled explicitly, and moved rather than discarded where
they can be: the duplicates were separate tiles in the grid, so a guest's
comment or an admin's rating could legitimately be on either, and dropping it
inside a fix for silent data loss would be its own bug. Where the target
already holds an equivalent row — the same guest's like, the same admin's
mark, the same transfer's entry — the loser is dropped, because those tables
mean one row per (photo, actor). photo_faces is the deliberate exception: both
rows were scanned, so moving would duplicate every embedding and split the
person clusters built from them.
Legacy restores. Suspending FK enforcement does not suspend a UNIQUE index on
either engine, so a .picpeak backup taken before migration 186 — carrying
exactly the duplicates it removes — would hit the new index mid-batchInsert
and roll the whole restore back, after every table had already been emptied.
The restore now drops the index for the load and rebuilds it after running
the same dedupe.
Also: a failed CREATE INDEX is no longer swallowed. Recording the migration as
applied without it leaves the install permanently racy, with nothing to
trigger a retry.
The shared work moves to services/externalPhotoDedupe.js, which the migration
and the restore both call.
* fix(external-media): reconcile derived state around the dedupe (#1162)
Second review round, four more real findings.
The index throw did not actually stop anything. run-migrations-safe.js treats
23505 as "schema already exists" and marks the migration applied
(run-migrations-safe.js:138) — and a CREATE UNIQUE INDEX that finds duplicate
rows raises exactly 23505 on Postgres. A replica inserting one between the
dedupe and the index lock is a real rolling-deploy shape, and the outcome was
the thing the throw was added to prevent. The index is now verified against
the catalog afterwards, and failure raises a code-less error the runner cannot
mistake for idempotence.
Two people sharing a device were treated as one. photo_feedback carries both
guest_identifier (per device) and guest_id (per person, migration 078), and
feedbackService scopes by guest_id when present. Keying equivalence on the
identifier alone deleted one of two different people's ratings. It now uses
the same COALESCE rule the service does.
Deleting faces raw left ghost people. event_people counts and centroids are
derived from the photo_faces rows being removed, and #1132's separation
snapshots hold a copy of each side's centroid — which is why faceProcessor
exposes purgePhotoFaces and says it is "called from every photo-deletion
path". The dedupe now goes through it.
Reparenting feedback left the survivor's totals stale. photos carries
denormalized feedback_count / like_count / average_rating / favorite_count and
the later reaction and colour counts, so a survivor that now owns feedback kept
rendering zero. updatePhotoFeedbackStats takes a trx so the dedupe can
recompute on its own connection.
Also: the equivalence-key delimiter was a literal NUL byte, which made git
classify the whole file as binary and hide its diff. Escaped.
* fix(external-media): stop the dedupe discarding half-states (#1162)
Third review round. Five findings, four applied.
- is_hidden joins the feedback equivalence key. feedbackService lets a
moderator-hidden row coexist with the guest's visible replacement and counts
only the visible one, so ignoring it deleted the visible row as redundant.
- admin marks merge instead of dropping. rating and color_label are written
independently, so the same admin can have rated one tile and coloured the
other; the loser now hands over any field the winner has no value for.
- a survivor that loses the only completed scan is requeued. Otherwise the
purge takes the sole embeddings and nothing re-queues it — the photo just
silently stops having a face.
- view_count and download_count are carried over. Those are real interactions
recorded per row, and dropping them quietly lowered the engagement the admin
grid shows.
Not applied: repointing a category hero can in principle land on a survivor in
another category. It needs the two duplicate rows to have been re-categorised
apart after the racing import, and the result is a cosmetic hero mismatch that
the admin category routes already guard on write. Not worth the extra branch
in a data migration.
* fix(external-media): invalidate the download zip when duplicates are removed (#1162)
External review of the stable twin. Applies to both branches.
The pre-built "download everything" archive still contained the duplicate rows
the dedupe had just deleted, so guests kept receiving them until something
else happened to invalidate it. 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 is not something a
migration should start. getZipInfo already treats a cleared record as a cache
miss and rebuilds on the next request, so this is the durable half of what
invalidate does. The stale object is left in storage for the same reason the
duplicates' thumbnails are — a migration is the wrong place to reach into a
backend that may be S3.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
A separation — an explicit dismissal, or the implicit one a Split records — was stored as a pair of event_people.id. Those ids do not survive re-derivation: recluster() deletes every person, and a full re-scan replaces a photo's faces outright, so face ids die too. The only thing that survives both is the embedding, so the decision is keyed on the two centroids the pair had when the photographer separated them. It binds while both sides still look like the clusters that were separated, and lapses once they have drifted past recognition.
The constraint is now honoured at assignment time as well as in consolidate(), which is what makes it hold across a re-scan rather than being reformed before any later pass could object.
Six review rounds shaped the matching itself: each candidate must resolve to the OPPOSITE side rather than merely matching something (a split leaves two similar halves, and the loose test fragmented the person the split was not even about); assignment judges both sides at the ordinary match threshold, since a single face — or a cluster of one part-way through a recluster — cannot resemble a settled centroid; separations carry their own model_version; and the projections are hoisted out of the innermost loop, which took a 2000-photo scan from ~15s of dot products to 0.23s.
Lifecycle closed three ways: purgePhotoFaces re-anchors each side onto the live cluster it still describes and drops rows that describe nothing left, deleteEventCascade and the permanent archive delete clear the table (which deliberately has no event FK), and a later manual merge drops the separations it reverses. All of it matched on vectors rather than ids, since a row that has outlived a recluster names people who no longer exist.
Merged with admin privileges: the author cannot self-approve.
Everything in the system treats a hidden row as absent — getPhotoFeedback drops it even for the guest's own feedback, and updatePhotoFeedbackStats does not count it. The per-viewer is_liked heart and my_color_label badge 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 those agree exposes why it had not been fixed: 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 — the click did nothing visible and the moderation was silently undone. Skipping hidden rows there makes the click create a fresh, visible row.
Review found four more surfaces still treating a hidden row as present: the per-guest caps (an at-cap guest with one hidden met their own click with limit_reached), /my-feedback (which drives the Liked/Favorited/Rated chips in guest identity mode), getEventFeedbackSummary (disagreeing with the photo counters in the same response), and unhide (leaving two visible rows for one guest). The rating-clear and single-value delete scopes are visible-only now, so a follow-up mutation no longer destroys the admin's hidden record, and the unhide collapse is skipped when there is no stable identity to scope by — that fallback was 'guest_identifier IS NULL', i.e. other visitors' rows.
Not taken: refusing to hide non-comment feedback, which the issue recommended. #839 and #1044 both ship hiding for reactions and colour labels with tests asserting a hidden one stops counting; only the admin UI's Hide button is comment-only.
Merged with admin privileges: the author cannot self-approve.
Two follow-ups from the review of #1137.
Filters were a second way to read hidden feedback. Every token on /photos is an OR of two halves: what THIS viewer marked, and what ANYONE marked. The response fields built from the second half — like_count, comment_count, color_label_count — are all gated on show_feedback_to_guests. The filter was not, so with the setting off a guest could still send ?filter=liked and get back exactly the photos other people liked, across all five tokens. Reachable by a direct API caller holding a gallery token; the frontend never sends filter to this endpoint.
The half it left standing was also the wrong half. It read guest_identifier from the guest_id QUERY PARAMETER, which never matched anything — the frontend invents that string in localStorage and never sends it when submitting feedback, while submissions store generateGuestIdentifier(req). So gating the aggregate would have emptied these filters rather than narrowing them to 'mine', 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.
A mark whose row is cleared mid-write lost its value. #1137 fixed two calls both writing; this is one clearing while another sets. The clear empties the row, the row is deleted for being empty, and the setter's update matches nothing — the caller told 'no mark'. A zero-row update now reports itself and the caller re-reads, bounded at three passes, throwing rather than reporting a success that did not happen.
Merged with admin privileges: the author cannot self-approve.
showLogout was hard-coded true, so a gallery with no password showed a Logout button. Logging out of it is meaningless — no credential to drop, nothing to return to — and it stranded the visitor: GalleryPage's auto-login is a one-shot latch, so clearing the session left the page on its skeleton until a manual reload. That is the 'turns blank' in the report.
The button is gated on requiresPassword || isClient || viaCustomer at both call sites. The full-page layouts render it on the callback being present rather than on a flag, so withholding the callback is how the gate reaches them.
Session kind now comes from /auth/session rather than sessionStorage, which is per-tab while the cookie is per-browser: a gallery reopened in a second tab lost 'client' while the backend kept serving it as one. viaCustomer marks a portal token, which bypasses reveal mode and so is a credential that does not look like one.
The public-gallery branch no longer returns the skeleton unconditionally — once auto-login has run and left us unauthenticated it shows the reason and a Retry. That state was otherwise unrecoverable, and it also swallowed loginError entirely.
Merged with admin privileges: the author cannot self-approve.
The CLI fallback carried the defect #1129 fixed in the admin route: it computed `storage/events/active/<photo.path>` and fs.access'd it, a location that does not exist for external or reference rows. Every one failed the check and was counted as an error, so on an external-media install the script was inert while reporting one error per photo.
Resolution now goes through ensureThumbnail, which already branches on source_origin and owns the per-photo ext<id>_ output name — sharing it is what stops the script and the route drifting apart again.
Also: videos skipped on every marker they can carry (fileWatcher writes type and mime_type but never media_type), responsive tiers backfilled alongside the canonical rendition, skip-vs-generate asked from isThumbnailValid rather than inferred from an unchanged path, tier failures counted rather than swallowed, and a nonzero exit when the backfill was incomplete.
The script is now importable with the CLI behind a require.main guard; it previously ran on require and called process.exit, so it could not be tested at all — which is why this survived #1129.
Merged with admin privileges: the author cannot self-approve.
Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid.
- Guest colour labels alongside likes/reactions, opt-in per event (defaults off so live galleries do not change mid-proofing), with 'colors' and 'lightroom' keybind schemes.
- One global default per feedback type, replacing the per-type scatter.
- Admin marks live in their own table (photo_admin_marks) so they can never reach a guest-facing surface.
- XMP export prefers a real label, keeping the rating-derived mapping as a fallback.
Review: concurrent-write loss on the mark update path, migration index idempotency and error classification all fixed in 7139bcae; migrations renumbered to 182/183 in 8fecdfae after 180/181 were taken on main.
Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
consolidate() has existed since #1074 and described this exact symptom in its own
comment, but its only caller was recluster() — i.e. when an admin pressed
Re-group people. After a normal background scan the centroids converged and
nobody looked, so a gallery settled with 14 people that should have been 8.
It now runs when a scan drains. There is no scan-finished event to hook, so an
idle worker asks whether the events it touched have actually drained — 'a worker
went idle' is deliberately not treated as sufficient, because with concurrency
above one the others may still be working.
The uncertain band asks instead of acting: pairs between the assignment
threshold and the stricter auto-merge one surface as accept/dismiss suggestions,
with sticky dismissals. Nothing merges silently — a pass that merged anything
reports it and points at Split.
Review rounds hardened it against overruling explicit decisions: it no longer
absorbs ignored clusters (mergePeople ORs is_ignored onto the survivor, which
would have hidden a real person), no longer merges dismissed pairs, no longer
undoes a manual Split (which now records a separation), and no longer runs after
detection is switched off. The dismissal read fails closed, a failed pass is
retried with backoff rather than lost or hot-looped, and the new table follows
event_people out of exports and backups.
Name autocomplete needs no endpoint — the people list already open is the source,
and it is event-scoped on purpose.
Known limitation, tracked in #1132: separations are keyed on person ids, so a
full re-scan loses them.
Reported by @BraynArts.
Every other feature routes readers to docs.picpeak.app. Face recognition was the
one that either pointed somewhere else or pointed at nothing — poor placement
for the feature with the highest read-before-you-enable burden anything here
ships.
.env.example referenced docs/feature-face-recognition.md, which does not exist —
and creating it is not the fix, because .gitignore:89 ignores docs/feature-*.md
outright, so the file would be invisible to anyone who cloned. That was the only
pointer to legal guidance an operator got while editing the variables that turn
Art. 9 processing on.
Also: the README linked the sidecar's developer README for the feature name and
had no row in the documentation table, docs/single-container.md left readers who
wanted the feature nowhere to go, ml/README.md had no backlink, and the admin
consent callout had no link at all. It does now, inline at the end of the
obligation.
Reported by @Luca-Timo.
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 the intrinsic ratio of whatever rendition was served. With
thumbnail_fit seeded 'cover' by migration 040 every rendition is square, so the
layout drew identical squares and was indistinguishable from grid. The card now
uses the height it is given and the stylesheet's existing height:100% applies.
The bundled CSS templates pinned images to a fixed pixel height, which has
specificity (0,1,1) and beats the .h-full utility (0,1,0) six of the seven
layouts use. 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; 181 repairs the rows already
seeded. Whitespace-tolerant because sanitizeCSS strips newlines from any
template ever saved through the editor — an exact-text migration would have
silently no-opped on most real installs. The height property is matched with a
lookbehind so line-height/max-height/min-height are untouched, grouped selectors
are handled, and nested rules are skipped rather than mis-rewritten.
Both reported, measured in the live DOM, by @BraynArts.
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, so every one failed and was counted as an error — and
because the tier deletion runs first, the endpoint dropped every ?w= tier and
rebuilt nothing, leaving the library worse than before it ran. The UI reported
success either way.
Now routed through ensureThumbnail, which resolves both source kinds, uses the
per-photo ext<id>_ output name, and writes thumbnail_path back itself.
Review rounds also removed both destructive deletes in generateThumbnail: the
pre-delete ran before sharp opened the source, so an unreadable source left the
previous rendition gone and the database pointing at it — across a bulk run,
the whole gallery. Neither delete was needed, since put stages to a temp file
and renames atomically and is the last statement in the try.
Videos are filtered out, and the superseded rendition is removed only when the
storage key actually moved, compared through the same canonicalisation the
backends apply so a legacy backslash path is not mistaken for a different
object.
Reported by @BraynArts, who also identified the fix.
The first load of a gallery whose ?w= tiers do not exist yet could exit the Node
process — not 500 one tile, kill the backend. Two defects stacked.
The reader: LocalFsStorage.get() returns a lazy fs.createReadStream, so an ENOENT
arrives after the await returned and outside the route's try/catch. An unhandled
'error' event is a process-level throw. pipeStreamToResponse attaches the handler
the routes were missing — 404 for a vanished source, connection destroyed if
bytes are already on the wire, file headers cleared so the JSON error is not
served as image/jpeg or cached as a broken tile for an hour. Applied to all nine
streaming responses in gallery.js.
The writer: ensureThumbnailAtWidth passed regenerate:true, whose first act is to
DELETE the target — on a path only reached when the tier is absent. A grid fires
one request per tile, so one request unlinked the file another had just published
and handed to a reader. Without the flag the write is an atomic rename.
Generation is now also deduped per tier key: 8 concurrent requests ran 5 Sharp
passes before, 1 after.
Reported with a full diagnosis by @BraynArts.
The all-in-one image could not be installed from a GUI at all — the deployment it
exists for. validateEnv treats a missing JWT_SECRET as critical and exits, and the
documented run command supplies it with `openssl rand`, a shell command a Synology
Container Manager or QNAP Container Station form cannot run.
wait-for-db.sh now generates one on first start and persists it next to the database,
extending the existing /run/secrets hydration rather than adding a second mechanism.
Explicit env still wins, then /run/secrets, then the generated file. The write is
load-bearing: JWT_SECRET is exported only when the file actually persisted, because an
unpersisted secret would mint a new one every restart and sign every session out.
Creation writes to a private temp file and hard-links it into place — atomic, fails with
EEXIST when another container won, and the loser adopts the winner's value. Non-regular
paths are rejected before the link, since POSIX ln links INTO a directory rather than
failing, which would make a mistyped -v target unrecoverable.
Also repairs the onboarding paths a new install actually walks: the installer no longer
rotates the secrets of a running install on re-run, deprecates the dead scripts/install.sh
in place, corrects the CONTRIBUTING dev loop, and fixes the vite proxy target that had
been pointing at a stray local port since 0da45e69.
Reviewed over three rounds. Co-authored by @Luca-Timo.
Every component added by #1074 was styled for light mode only. In the admin dark
theme the three toggle labels rendered invisible — including "Detect people in
this gallery", the switch that starts GDPR Art. 9 processing — and the
Manage-people modal rendered as a light panel over a dark page because its shell
was a hardcoded bg-white.
Admin surfaces pair each neutral with a dark: variant; guest surfaces read the
gallery theme tokens, because galleries carry their own dark themes that the
admin dark class knows nothing about.
Beyond the issue's inventory: the cover picker and face-in-context viewer that
landed after it was filed, the magnifier chip whose bg-white/90 would have
carried light glyphs, PeopleSheet's own hardcoded bg-white shell, the selected
avatar's white ring-offset halo, and both dismiss buttons whose hover darkened
into the background.
External review found one defect, fixed: the sheet's avatars ring against
--color-surface, not the page background.
Phase 2 of #1096. Stacked on the phase-1 branch — it needs the Postgres fix
there, or the face list this reads comes back empty.
A 64px avatar answers "is this a person", not "is this the same person as that
other cluster". The reporter's revised use for this is the pre-merge decision:
who they were standing next to, what the occasion was. So the face opens in
its own photo with the detected box drawn, and prev/next walks that person's
other appearances without leaving the modal.
The box is positioned in PERCENTAGES of the original frame, not measured
pixels: the container carries the photo's aspect ratio, so the same four
numbers land correctly at any rendered size, with no resize listener. Verified
against real data before writing the component — bbox [221.9, 174.9, 294.5,
405.8] on a 750x750 frame resolves to left 29.6% / top 23.3% / width 39.3% /
height 54.1% and lands squarely on the face.
Preview rendition, never thumbnail, and that is load-bearing rather than a
quality preference: thumbnail_fit is seeded to 'cover' on every install, so a
thumbnail has had its edges cut off and ratios taken against the ORIGINAL land
nowhere on it. That was #1100, and it presented as a broken detector.
Not built on AdminPhotoViewer, deliberately. It wants full AdminPhoto objects
(this endpoint returns photo_id + bbox + dimensions), it carries delete and
category actions that are wrong for "who is this?", and there is no seam to
draw the box.
Three things review caught, all real:
- The container had a height cap but no width cap, so a panorama derived its
width from the aspect ratio and overflowed the modal sideways, taking part
of the outlined face off-screen.
- The per-tile affordance was hover-only, so on a tablet it was permanently
invisible and there was no way to inspect a specific tile.
- The row action opened index 0, which is the TOP-SCORING face — the same
thing as the cover only until someone uses phase 1 to pick a different one,
at which point the row showed one face and opened another. It now resolves
to the cover's own index.
Round 2 found three more, all real:
- The counter called a list truncated whenever it hit 500, so a person with
exactly 500 faces was told their complete list was capped. It now compares
against total_face_count.
- facesLoading goes false with an empty array on a zero-face person or a failed
request, so the panel sat on a spinner that would never resolve.
- Five 32px actions plus a 64px avatar exceed a 320px row, and the name is what
got pushed out. flex-wrap alone did not fix it — the toolbar still claimed
its max-content width first — so its basis is capped at small sizes and the
buttons wrap to a second line instead.
A cover that falls outside the capped list opens the first face instead. That
case implies the list IS capped, so the truncation note already explains it —
real pagination is a bigger change and is not in this.
Verified end to end: picked the 5th of 13 faces as cover, and the row action
opened at 5 / 13 rather than 1 / 13. Frontend suite 178 passing, build clean,
no new type errors.
Phase 1 of #1096.
Clustering picks the cover, and its idea of a good one and a human's do not
always agree. A cluster whose avatar is turned away or softer than the rest
stays that way in the guest-facing people strip too, and nothing in the UI
could change it.
A picker reachable from each person row, reusing the face list the split
dialog already loads — same query, same grid, different action on a click.
Making the choice actually stick took four changes
---------------------------------------------------------------------------
event_people.cover_face_id has existed since migration 177 and the PATCH
already accepted it, so the first version of this was frontend-only. It was
also a no-op:
- facePeopleService.listPeople SELECTED cover_face_id and then discarded it,
recomputing the cover as the best-scoring VISIBLE face on every read. The
picker saved, said so, and the avatar reverted immediately. It now prefers
the stored pick whenever this audience can see it, and falls back to the
score-ordered choice otherwise — so visibility scoping still wins, and a
guest is never handed a crop of a photo they cannot open.
- recomputeCentroid overwrote cover_face_id unconditionally. It runs on
rescan and on photo replacement, so any reprocessing silently undid a
deliberate choice. It now keeps the chosen face while it is still a member
of the cluster.
- The face list is cached per person, and split/merge move faces between
people. Until now the only reader closed itself after acting, so nobody saw
the stale copy; the picker is a second reader of the same key.
- cover_face_id meant two things. assignFaces seeded it with whichever face
opened the cluster and recomputeCentroid overwrote it with the highest
scoring one, so an automatic guess was indistinguishable from a deliberate
choice — and honouring it would have pinned every UNCURATED person to that
guess, which is worse than the fallback it replaced (the fallback is
computed per audience and skips photos a guest cannot open). Both writers
are gone, migration 179 clears the stored guesses, and the column now means
one thing. That also removes the need to defend the choice against rescans:
nothing overwrites it, and a dangling id self-heals to the derived cover.
Clearing existing values is safe rather than destructive: no install has ever
been able to SET a cover, so every stored value is an automatic guess by
construction.
Also fixes a PostgreSQL-only 500
---------------------------------------------------------------------------
GET /admin/events/:id/people/:personId/faces joined `photos` but did not
table-qualify its WHERE, and photo_faces and photos BOTH have an event_id:
column reference "event_id" is ambiguous
Postgres refuses it, so the endpoint 500s and the Split dialog — its only
consumer until now — has been broken on every PostgreSQL install since the
join was added. SQLite resolves the ambiguity silently, which is why the suite
stayed green. Reproduced against a real Postgres before and after.
The query is now a named builder the route calls and the test imports, rather
than a copy: an earlier version of that test re-declared the query, so the
route could regress to the bare form while the assertions kept passing.
Merge and recluster preserve the choice as well. Both already carried labels
and privacy flags across; the chosen cover is human state of the same kind, so
it now rides along — through a merge when the target has none, and through a
recluster by following its FACE into whichever cluster ends up holding it,
rather than the majority-descendant rule the label uses.
The picker and the endpoint disagree past 500 faces, so the picker now says
when it is showing a capped list rather than presenting it as exhaustive.
Frontend suite 178 passing, backend 23 across the touched suites, build clean,
no new type errors. Mutation-checked twice: dropping the cover preference fails
the new listPeople test while the visibility-scoping test still passes, and
restoring the auto-seed in assignFaces fails it too.
* feat(gallery): responsive grid thumbnails (#1095)
The half of #1095 that #1099 deliberately left out. Grid tiles are ~175
CSS px at the mobile 2-column default — about 530 device px on a DPR-3
phone — so the 300px thumbnail is upscaled ~1.8x and faces visibly mush.
Backend mirrors the preview tiers exactly: ?w= on the gallery thumbnail
route, whitelisted to 300/600/900, cached by width in storage, never
written to photos.thumbnail_path, and keyed by photo id for every source
type — basenames are not unique across events and a tier is served from a
cache hit without re-reading the source, which is how the preview tiers
nearly leaked one gallery's photo into another. The tier is in the ETag,
or a client holding the 300px file gets a 304 for its 600px request.
Cleanup and regenerate invalidation are wired the same way.
generateThumbnail now takes width/height overrides; it keeps the
configured `fit`, because the grid renders with object-cover and tiers
that were framed differently would visibly jump as the viewport changes.
The srcset only advertises tiers the SOURCE can fill. Thumbnails are
generated withoutEnlargement, so a 400px original asked for 900 comes
back at 400 — advertising "900w" would have the browser pick that
candidate and upscale it, which is the reported softness made worse. That
exact trap is why this was held back from #1099; the photo's own
dimensions are now the guard, measured on the SHORT edge because
thumbnails are square and a 4000x600 panorama can still only fill a 600
tile. A source that clears only one tier gets no srcset at all rather
than a single pointless candidate.
Two things this surfaced, both worth knowing separately:
`npx tsc --noEmit` type-checks NOTHING in this project — the root
tsconfig is `files: []` with project references, so the real command is
`tsc -b`, which is what build:check runs. Under tsc -b the repo has 43
files with pre-existing type errors; this branch adds none, and the one
error in a file I touched (PeopleManagerModal:91) is on main already and
unrelated to the line I changed.
* fix(gallery): wire grid tiers into the component that actually renders
The srcSet landed in PhotoGrid.tsx, which nothing imports — GalleryView
renders PhotoGridWithLayouts, and every grid layout funnels its tile
through the shared PhotoCard. The frontend half of #1095 shipped nothing.
Moved to PhotoCard, and switched from srcSet to a single sized URL, the
same shape PhotoLightbox already uses for preview tiers. AuthenticatedImage
fetches its src with the gallery bearer token and renders the blob; an
<img> carrying a w-descriptor srcSet ignores src entirely, so that fetch
would have been discarded and the browser would have issued its own —
unauthenticated, and resolved against the page origin rather than the
configured API host. One URL keeps the auth path and halves the requests.
The tier comes from the tile's measured width via the IntersectionObserver
entry, read on the same render that reveals the image so nothing is fetched
twice. Column counts differ per layout and shift again with thumbnailScale,
so the breakpoint table is only a fallback.
Also closes what the tier cache leaked or served stale:
- ensureThumbnailAtWidth short-circuits videos. Their thumbnail is a poster
frame, so the tier path handed the video file to Sharp — after downloading
it in full on S3, uncached, once per request.
- The ETag names the tier actually served, not the one requested. A fallback
to the canonical thumbnail was caching a 300px image under a 900px key.
- Tier height scales from the configured aspect ratio instead of forcing a
square; with fit:'cover' a 300x200 canonical and a 600x600 tier are two
different crops and the photo reframed between tiers.
- The canonical short-circuit compares against the configured thumbnail_width,
not the 300 default, so a 600px install stops generating duplicate tiers.
- Tier invalidation on /admin/thumbnails/regenerate, above the local-file
check that skips S3 and external rows.
- Tier cleanup in replacePhoto and deleteEventCascade. Both derive keys from
the photo row, so the rows have to be read before they change or vanish.
Preview tiers had the same two holes and are swept alongside.
The clamp no longer drops a tier when the source falls between them: a 400px
short edge asked for 600 returns all 400 pixels, where clamping to 300 threw
100 of them away.
Backend 18 tier tests, frontend 22. Full suites green: 293 backend across the
touched areas, 185 frontend, build clean, no new type errors.
* fix(gallery): measure the tile, and stop regenerating the w300 tier
Follow-up to the review of #1095. Closes the three items left open there,
plus a defect the previous commit introduced.
**The w300 tier regenerated on every request.** Decoupling the canonical
short-circuit from the hardcoded 300 left generateThumbnail still tagging
against DEFAULT_THUMBNAIL_WIDTH. On an install with thumbnail_width=600 a
w=300 request wrote `thumb_<name>` while the caller probed for
`thumb_w300_<name>`: the cache never hit, so every request re-downloaded the
original and ran Sharp, and the file it left behind was in no cleanup list.
The tag now follows the configured width, and thumbnailTierKeys lists all
three widths — which one is canonical is a setting, so excluding 300 stranded
exactly the file a 600-configured install generates.
**The tier is chosen from the tile's measured width.** The observer entry
only exists for `lazy` cards, and Mosaic, Masonry and Timeline don't pass it
— Mosaic is 1-up on mobile where Grid is 2-up, so they are the layouts a
breakpoint guess gets most wrong. Measured in a layout effect and gated: the
image is not rendered until the width is known, so AuthenticatedImage never
mounts with a src it has to replace. Attaching the observer ref
unconditionally instead refetches every tile, since React flushes passive
effects before the sync re-render a layout effect triggers — removing the
gate makes the new single-request test fail, which is how that was confirmed
rather than assumed.
**Gallery Premium has its own card** and never reached the shared one, so its
tiles kept pulling the canonical thumbnail. MasonryPhotoAlbum already hands
the laid-out width to the render prop, so it needed no measurement.
**Event rename orphaned tiers.** The key embeds the basename, so the DB
update is the point past which the old keys cannot be derived. Dropped inside
the filename-changed branch, not the loop body: unconditional would fire four
storage deletes per photo on every rename, 20k calls against S3 for a
5,000-photo event that merely had its slug adjusted. Preview tiers had the
same hole and are swept alongside.
Carousel is the seventh layout and deliberately gets no tiering: its
filmstrip thumbs are 80 CSS px, under the canonical 300 even at DPR 3.
Tests: first PhotoCard suite (6), backend tier suite 21. Both new behaviours
mutation-checked — reverting the width tag, the render gate, the measurement,
or the rename sweep each fails a test. Full suites green: 298 backend across
the touched areas, 191 frontend, build clean, no new type or lint findings.
* fix(gallery): mount masonry cards once, into a measured layout
Found while capturing screenshots for this PR, by attributing every thumbnail
request to a photo id rather than eyeballing the grid.
Masonry columns mode starts at 3 columns and runs its greedy distribution off
a hardcoded 300px estimate until the container has been measured. Cards
mounted into that guess are torn down when it settles — photos move to a
different parent column, so React unmounts them — and since #1095 each mount
picks its tier from its own width, the two mounts request two DIFFERENT urls.
Measured on a 1440px desktop, production build, 62 photos:
before 45 photos fetched at canonical AND w600, 17 stuck on w600
107 requests
after 62 photos, canonical only, 62 requests
Mobile was already landing on one tier either way, so both mounts produced the
same url and the second was a cache hit — which is why it looked clean and the
desktop case did not.
The fix is the gate the rows/justified mode in this same file already applies
for the same reason (line 346): hold the cards back until containerWidth is
known. Only columns mode was missing it. Grid and Justified take their column
counts from CSS breakpoints, so they have no transient measured value to
discard and are unaffected.
Worth noting this was NOT visible on main: without tiering both mounts request
the same url, so the browser cache absorbs the duplicate. Tiering is what turns
a harmless remount into a second download — the regression is this PR's, which
is why it is fixed here rather than deferred.
Frontend suite 194 passed (3 new). Mutation-checked: removing the gate fails
the mount-once and placeholder tests.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Closes#1116.
secureImageMiddleware set its own Access-Control-Allow-Origin, overwriting the
one cors(corsOptions) had already computed. server.js:247 mounts cors() on all
of /api with credentials:true, so by the time the route handler ran the correct
header was already there — and the local assignment replaced it with a worse
answer in BOTH directions:
unresolved -> '*'. Combined with the credentials:true that cors() sets, that
is an invalid pair browsers reject outright. Unreachable on Docker until
#1104 stopped compose injecting FRONTEND_URL; reachable on a fresh install
from then until the wizard stores general_site_url.
resolved -> the frontend origin, even when the request legitimately came
from the allowlisted ADMIN_URL. A split admin host got a header naming the
wrong origin and the browser rejected a request cors() had allowed.
Deleting the line fixes both. cors() already validates the request Origin
against the allowlist, sets Vary: Origin, omits the header entirely for a
disallowed or absent Origin, and pairs correctly with credentials. Methods,
Headers and Max-Age stay here: they are route-specific and cors() does not
contradict them.
Observed against a running instance before and after:
allowlisted Origin ACAO: <that origin> + Vary: Origin + credentials:true
disallowed Origin no ACAO
no Origin header no ACAO
Six tests, mounted on a real Express app with server.js's middleware order.
Deliberately NOT a unit test against a response double: the first version of
this fix was a guarded assignment that looked correct in isolation and still
overwrote cors() whenever an origin resolved. A double cannot see middleware
composition, which is exactly how that slipped through.
Mutation-checked both ways — restoring the original `|| '*'` fails 5 of 6, and
restoring the guarded assignment fails 3 of 6 including the admin-origin case.
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.
The backend job normally finishes in about 3 minutes — the last eight
runs on main were 2.6 to 3.4 — but it is the only one that boots
Postgres and runs the full integration suite, so it is the only one
exposed to runner contention. The observed spread has reached 9.2
minutes against a 10-minute cap, and release PR #1088 was cancelled at
10.3 with every test in the log passing and jest still running.
That failure mode is expensive out of proportion to how often it
happens: a cancelled job is a red X on a branch that is actually green,
so it costs a diagnosis and a re-run each time, and it lands on release
PRs because those are the ones that run when everything else does.
The cap is a runaway guard rather than a performance budget, so 20 buys
real headroom over the worst run seen while still killing a genuinely
hung suite well inside the hour GitHub would otherwise allow.
frontend and ml keep 10: they finish in seconds and have never been
close.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(setup): configure the public address and SMTP in the wizard, not .env
A fresh install could not configure its own public address. `general_site_url`
and the `email_configs` row already existed as admin settings, but nothing
could reach them:
- docker-compose.yml injected FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
and Dockerfile.aio baked in ENV FRONTEND_URL=http://localhost:3000, so
getFrontendBaseUrl() returned on its first branch every time and the setting
was never read. .env.example shipped the same value as an uncommented
placeholder for FRONTEND_URL / ADMIN_URL / API_URL.
- the wizard never asked for the address at all, and skipped its whole config
step unless a CRM-ish feature was selected — so a gallery-only install was
also never offered SMTP, despite gallery links, guest invites and expiry
warnings all going out through email_configs.
- eleven call sites read process.env.FRONTEND_URL directly rather than the
resolver, three of them defaulting to placeholder hosts that reached real
recipients: https://app.example.com in payment-reminder emails, localhost:3005
in admin invitation emails, https://app.example.com in dev template previews.
Stop injecting a default anywhere, and resolve the origin instead:
FRONTEND_URL -> general_site_url -> the origin the request arrived on ->
whichever exists -> ''. A loopback candidate is treated as unconfigured so the
installs that already have http://localhost:3000 baked into their environment
self-heal; the same guard previously lived inline in routes/gallery.js for the
slideshow QR (#848) and is now shared. The empty return is preserved because
shareLinkService and the SSO redirects in routes/auth rely on it to emit
relative urls — callers needing an absolute url use getAbsoluteFrontendUrl(),
which still ends at http://localhost:3000.
The wizard now persists window.location.origin right after the admin account is
created, so an install that skips the rest still has a usable origin for
background jobs that have no request to derive one from, and offers it as an
editable "Public address" field. Settings -> General shows the field read-only
when FRONTEND_URL pins it, instead of silently ignoring edits.
Also drop the `|| 'mailhog'` fallback when seeding email_configs: that host only
exists in the dev compose profile (which does not even start by default), so a
fresh install came up with a live config pointing nowhere while the wizard
showed empty SMTP fields. With no row, blank fields are the truth and
emailProcessor logs "No email configuration found". Developers set
SMTP_HOST=mailhog explicitly.
backend/src/services/emailService.js is deleted: nothing in backend/ references
it, and it was the only consumer of the SMTP_* variables, which misrepresented
how mail is configured.
Refs #705
* fix(setup): keep FRONTEND_URL ahead of ADMIN_URL/APP_URL when resolving links
The previous commit routed two call sites through the resolver but put the
site-specific variable FIRST, silently reversing precedence:
userManagementService was: FRONTEND_URL || ADMIN_URL || localhost:3005
became: ADMIN_URL || resolver
adminEvents/crud was: FRONTEND_URL || APP_URL || ''
became: APP_URL || resolver
An install with both variables set would have flipped which one won. Call the
resolver first instead — it starts with FRONTEND_URL, so the original relative
order is preserved and only the final fallback changes: localhost:3005 (not
even the frontend's port) and '' (a relative link inside an email) both become
the resolved origin.
Refs #705
* fix(setup): unpin loopback FRONTEND_URL, keep ADMIN_URL/APP_URL reachable
Review feedback on #1104.
isEnvPinned() reported ANY FRONTEND_URL as authoritative, including the
loopback values getFrontendBaseUrl() deliberately demotes. An install
upgrading with the old compose default FRONTEND_URL=http://localhost:3000
therefore resolved its origin from general_site_url correctly, but got the
Site URL field rendered read-only in Settings and skipped by the wizard's
seeding - locking the exact operators this change exists to unblock out of
configuring a public address anywhere. The predicate now mirrors the
resolver, and the derived general_site_url_effective the General tab reads
comes from the same helper instead of re-normalising process.env inline.
APP_URL and ADMIN_URL had become dead code: getFrontendBaseUrl() only
returns falsy when NOTHING is configured, so `|| process.env.ADMIN_URL`
after it never ran once a site URL existed - which after this PR is the
normal case. A split-origin install pointing ADMIN_URL at a separate admin
host got invite links on the public gallery origin instead. They are now
passed as an explicit `override` that resolves directly below FRONTEND_URL,
preserving the historic FRONTEND_URL-before-ADMIN_URL order while beating
the database- and request-derived fallbacks.
general_site_url now feeds the CORS allowlist and the
Access-Control-Allow-Origin header, not just email links, so a schemeless
value is an allowlist entry no browser origin can match. Validate it
server-side in PUT /general (isURL with require_protocol, require_tld off
so LAN/NAS installs on http://nas:3000 still work) and client-side in both
surfaces that write it - type="url" never fires in either, since neither
input sits inside a form.
Two more wizard fixes: the General tab no longer reposts general_site_url
while it is env-pinned, because the field then holds the effective env
value rather than the stored one and the round-trip read as a change to a
protected key, 403ing a settings.edit-without-settings.domains admin on an
unrelated save. And SetupConfigStep validates the From address before
posting - /admin/email/config rejects a blank one, which used to surface as
a generic warning while the wizard advanced from its finally block anyway,
discarding every SMTP value the user had typed, password included. A failed
save now keeps them on the step.
* fix(setup): surface a rejected public address instead of swallowing it
Review round 2 follow-up on #1104, pushed onto the branch.
saveSiteUrl() caught and discarded every error. That was defensible before
round 2 added a server-side URL check, but PUT /general can now answer 400 —
and the two validators disagreed:
http://my_nas.local client: accepted server: rejected
http://foo_bar:3000 client: accepted server: rejected
validate() let those through, the 400 was swallowed, `failed` stayed false and
onDone() ran. The operator finished the wizard believing the public address was
stored when nothing had been. That is the silent misconfiguration this whole
change exists to remove, landing on the LAN and NAS installs it targets.
Three parts:
- saveSiteUrl() throws. finish() resolves it before anything else is posted and
puts the message on the address field rather than the generic "some settings
could not be saved" warning. Skip for now still always leaves, by contract,
but warns instead of dropping the value in silence.
- allow_underscores on the server check, for the same reason require_tld is
off: browsers resolve http://my_nas.local and the client accepts it, so
rejecting it server-side only produced the mismatch above. Both validators
now agree across the LAN/NAS, IDN, bare-IP and scheme-less cases.
- LOOPBACK_BASE_RE anchors its host token. Bare prefix matching also demoted
https://localhost-nas.example.com, and now that this predicate gates the
whole resolver rather than just the slideshow QR, being demoted means a
configured address is silently ignored. 127. stays a bare prefix on purpose:
all of 127.0.0.0/8 is loopback.
Resolver suite 31 passing, up from 26. Mutation-checked: restoring the
unanchored regex fails the three new host-boundary cases.
* fix(settings): don't lock the General tab on a site URL nobody typed
Review follow-up on #1104, pushed onto the branch.
general_site_url was free-text until this PR added a server-side check, so an
upgraded install can hold something schemeless that predates it. The tab
flagged that on load, and `disabled={!!siteUrlError}` then killed Save for
EVERY General setting.
An admin holding settings.edit but not settings.domains could not clear it
either: correcting the address is a change to a protected key and 403s. The
tab has no permission gating, so that role was simply locked out of the tab
with no self-service way back.
That is the same role adminSettings.js:85-95 documents the no-op round-trip
allowance for. The allowance only helps if the request is made, and this
blocked it in the browser first.
Validation now waits until the field is actually edited, and an unchanged
value is dropped from the payload rather than reposted — matching what the
env-pinned case already does one line above, and for the same reason.
stored value invalid, untouched Save works, key not sent
edited to something unusable Save blocked
edited to a usable absolute url saved
Four tests, first coverage for this feature. Mutation-checked: removing the
dirty gate fails the untouched-value case.
---------
Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
* fix(faces): face avatars were cropped against a cropped rendition
Found while triaging #1096, which reported the People manager showing
unusable cluster covers — a bare shoulder, the back of a head, a patch
of background — and asked for more sample faces to compensate. Most of
that is not a detector problem and not a UI limitation. It is a bug.
faceCropStyle positions an avatar by scaling the WHOLE frame and
offsetting so the face lands centre. That holds only while the rendition
shown is the entire image at a uniform scale. Thumbnails are not:
imageProcessor.js:93 DEFAULT_THUMBNAIL_FIT = 'inside'
migration 040:6 thumbnail_fit seeded to 'cover'
imageProcessor.js:229 fit: settings.fit
The 'inside' constant is only a fallback for a missing settings row, and
the row is seeded on every install — so thumbnails are centre-cropped
essentially everywhere, and every face avatar rendered against one is
silently offset on any non-square photo. The reporter read the setting
as safe because of that constant, and the code comment at :87-92 says
the same thing; all three places disagree with what is actually stored.
It presents as a bad detector, which is why it survived: the boxes are
right, the frame they are drawn against is not.
All three surfaces — the admin manager and the guest-facing strip and
sheet — now read a preview, which uses fit: 'inside' and is therefore
the whole frame. At w=640: plenty for a 64px avatar at DPR 3, and small
enough that a strip of a dozen people does not pull a dozen 1920px
renditions. Face scanning already calls ensurePreviewImage for anything
it scans, so a preview exists for every photo that has a face.
Adds the admin preview route the manager needed; the gallery already had
one. Both whitelist ?w= the same way.
The first version of the call-site test passed with every surface still
reading thumbnail_url, because an import alone satisfied it. It now
matches inside the src={...} expression, and each of the three surfaces
was individually reverted to confirm the test fails.
* fix(faces): size the face tier by bbox, and keep admin_preview auth
The face half of the external review; the tier-key and long-edge fixes
live on the #1099 branch this is stacked on.
Face avatars used one fixed 640 tier. In a 6000px group shot a 200px
face is ~21px there, and faceCropStyle then blows that up ~9x to fill a
64px avatar at DPR 3 — mush, and indistinguishable from the
mis-positioning bug this PR exists to fix. The tier is now derived from
the bbox's share of the frame, so a face across a hall gets 1920 and a
close-up still gets 640.
The synthesized face URL also dropped admin_preview. verifyGalleryAccess
only accepts the admin cookie when admin_preview=1 is on the request
(middleware/gallery.js:28), and the preview flow deliberately mints no
gallery JWT — so every avatar 401'd in exactly the mode an admin uses to
check a gallery before sending it to a client.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): sized preview tiers so phones stop pulling 1920px (#1095)
A phone can display ~1170px at most, but the preview tier is a single
1920px JPEG with no size parameter — so every lightbox swipe ships
roughly twice the bytes it can use, and the slide track preloads
neighbours, which multiplies it. On the reporter's all-external install
a null preview_url falls back to the untouched NAS original, which makes
it worse again.
Backend: ?w= on the gallery preview route, whitelisted to 640/1280/1920.
A whitelist rather than a free-form width because every distinct value
is a permanent rendition on disk — an open parameter is an invitation to
fill the volume. Unrecognised or absent values fall through to the
canonical 1920 preview, so old clients and hand-typed URLs behave
exactly as today.
Extra tiers are cache, not state: ensurePreviewImageAtWidth keys them by
width, looks them up in storage and generates on miss, and never writes
photos.preview_path. That column owns the canonical rendition, and
threading a width through it would mean the last size anyone requested
silently becomes "the" preview. Requesting 1920 resolves to the existing
preview rather than a w1920 duplicate, so no install grows a second copy
of every preview it already has.
The tier is part of the ETag. Without it a client holding the 1920
rendition gets a 304 for its 640 request and renders the wrong size,
which is this feature inverted.
Frontend: the lightbox picks a tier from innerWidth x devicePixelRatio,
capped at DPR 3 — uncapped, a DPR-10 device asks for 3900px and lands
straight back on the desktop rendition. At the top tier the URL is left
byte-identical so existing caches and ETags stay valid and desktop sees
no change at all. saveData and a 2g/3g effectiveType drop one tier;
both are Chromium-only, so they are a bonus rather than the mechanism.
Grid thumbnails are NOT tiered here, deliberately. generateThumbnail
resolves its width from admin settings rather than an argument, so
tiering it is a separate change — and shipping a srcset whose candidates
the server ignores would be worse than shipping none: the browser would
take the "600w" candidate, receive the 300px image and upscale it, which
is the reported softness made slightly worse. That half of #1095 lands
separately.
* fix(gallery): scope tier keys per photo, size by long edge, clean up tiers
External review. Three findings against the tier work, one a
cross-gallery leak.
The tier cache key was the photo's BASENAME. Managed uploads keep camera
basenames, so two events can each hold an IMG_0001.jpg — and a tier is
served straight from a cache hit without re-reading the source, so the
second gallery gets the first gallery's photo. Keys are now scoped by
photo id for every source type. The RAW branch passed proc.outputBasename,
which would have dropped that scoping again; it now passes the scoped name.
Tier selection used viewport WIDTH, but ?w= bounds the LONG edge
(fit:'inside'). On a 390x844 phone at DPR 3 a 2:3 portrait is bound by
height and renders ~1755 device px, so width-only picked 1280 and made
portraits softer than today; landscape on the same phone needs ~1170. It
now computes the rendered long edge from the photo's own dimensions and
falls back to the top tier — today's behaviour — when they are unknown.
Tiers live outside photos.preview_path, so nothing else knew they
existed: delete, bulk-delete and archive left them orphaned in previews/
forever, and regenerate-previews refreshed only the canonical rendition
while phones kept the stale copy. previewTierKeys derives them from the
same deterministic scheme and all four paths clean up. Deliberately
outside the preview_path guard — a tier can exist when the canonical
rendition never did, so keying cleanup off preview_path would strand
precisely the photos only ever viewed on a phone.
The existing tier tests encoded the old width-only semantics and were
updated rather than kept; that is a behaviour change, not a test fix.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(faces): defer on unreachable storage, and commit the import path first
The two follow-ups left open by #1091, both consequences of external
photos becoming scannable at all.
**A dropped mount no longer burns the gallery.** ensurePreviewImage
returns null for "this JPEG is corrupt" and "the NFS share is gone"
alike, and faceProcessor marked both 'failed'. Nothing re-queues a
failure automatically and the queue only ever claims 'pending', so a
mount that blinked mid-scan cost the whole event a manual Re-scan —
on external libraries, where network storage drops far more often than
local disk, that is the common case rather than the corner one.
faceProcessor now probes the containing DIRECTORY before failing, and
throws TransientSourceError when it cannot be reached; faceQueue treats
that exactly like SidecarUnavailableError — release to pending, back
off, retry — with the warning rate-limited to one per five minutes,
since an outage hits every photo in the event.
The directory rather than the file is the whole point: a missing file
inside a healthy directory is a broken photo and should still fail, and
it still does. Anything that goes wrong deciding which case it is falls
through to 'failed', because guessing 'transient' on an unknown
condition would retry forever.
**The import commits its path before inserting rows.** enqueueEvent
accepts processing_status NULL (faceProcessor.js:243-246), which these
inserts leave unset, so an admin hitting the toggle or Re-scan during a
long import could queue partial rows while the event still resolved
against the old directory — and burn them to 'failed'.
Moving events.external_path ahead of the loop closes that, and fixes a
pre-existing bug on the same line: an import that died at photo 500 of
1000 used to leave those 500 rows pointing into the new tree while the
event still resolved against the old one, making every one of them
unreadable. Safe to do first because the path is already validated
above, and existing photos are unaffected — photo.source_origin takes
precedence over event.source_mode in both resolvers and is NOT NULL
defaulting to 'managed'.
The #1090 test that asserted a missing source fails needed its setup
corrected rather than its intent: it created no directory at all, which
is now (correctly) a dropped mount. It now creates one, so it tests the
case it always meant to — healthy storage, dead photo.
Verified both fixes discriminate: removing the probe fails the defer
test, and moving the event update back after the loop fails the
ordering test. Face suite 59 passed across 8 suites; the full backend
suite fails the same 10 pre-existing suites as unmodified main, no more.
* fix(faces): stop a dead mount stalling the whole queue
External review, and the first finding is one my own change created.
Deferring by returning the row to 'pending' was a trap: claimNextPhoto
orders by id ascending and the queue defaults to a single worker, so the
same unreachable row becomes the oldest pending one after every backoff
and the worker never reaches a higher id. One dead mount would have
stalled face scanning for the entire install — unrelated events, fresh
uploads, everything. Strictly worse than the permanent 'failed' this set
out to replace.
The row is now left parked in 'processing' with face_started_at intact.
It is not claimable, so the worker moves straight on; the janitor that
already exists returns it to 'pending' past STUCK_TIMEOUT_MS, which is
the retry. No new column and no new timer. The sidecar branch still
releases, because a down sidecar blocks every photo anyway — there is no
other work to get on with.
Second: probing existence was not enough. Unmounting an NFS or SMB share
usually leaves the mountpoint behind as an ordinary empty directory, so
fs.access succeeded on storage that was entirely gone and the photo was
failed anyway — the exact case this was written for. An empty directory
where the photo should live now counts as unreachable. The trade is
deliberate and documented: a directory an admin genuinely emptied is
retried rather than failed, which now costs one attempt per janitor
sweep and nothing else.
Third: a comment in adminExternalMedia claimed source_origin isolates
existing photos from the early external_path update. That is true of
managed rows and false of external ones — resolveExternalPath prefixes
every external row with event.external_path, so importing folder B into
an event referencing folder A rebases the A rows. Pre-existing rather
than introduced here (the update always did this, just later), but the
comment asserted otherwise, so it now says what actually happens and
names the underlying single-base-path limitation.
The deferral test initially passed against the blocking version too —
database state alone cannot tell the fix from the bug. It now inspects
the branch directly, the way the #596 contract tests do, and fails when
releaseToPending is put back or the two branches are merged.
* fix(faces): back off per event, and stop clobbering concurrent scans
Round two of external review.
Parking a row in 'processing' fixed the head-of-line block but not the
cost: every janitor sweep handed the whole dead gallery back, and the
worker walked all of it again — one stat per photo against storage that
may be hard-mounted and slow to time out — before reaching any healthy
event. Every one of those attempts also went through
generatePreviewImage first, which logs an error per photo, so a down
mount produced a recurring flood that the rate-limited warning did
nothing about.
So the backoff is now per EVENT and separate from the janitor:
TransientSourceError carries the event id, the queue records a cooldown,
and claimNextPhoto excludes those events while it lasts. The janitor
keeps doing its own job, which is rescuing rows a crashed worker
abandoned. Cooldown is in memory on purpose — a restart is usually what
follows fixing a mount, so it should retry at once.
Second: committing the event path before the loop means a toggle or
Re-scan firing mid-import can now genuinely queue and finish some of
those rows. The final bulk update was unconditional, so it dragged
'done' rows back to 'pending' for a duplicate sidecar scan and knocked
'processing' rows out from under the worker. It is now whereNull —
only rows nothing has touched are ours to queue.
Also corrected a comment of mine that had gone stale in the same file:
it still described the enqueue as happening after the event path was
written "below", which stopped being true when that update moved above
the loop.
* fix(faces): judge the mount, the path and the file separately
Round three of external review. The probe was too coarse in both
directions.
It read any ENOENT on the photo's own directory as a mount-wide outage,
so a deleted or renamed subfolder — individual/ gone while collages/ is
healthy — deferred the entire event and starved every sibling folder,
renewing the cooldown on each retry. It now judges the EVENT ROOT for
that verdict: root missing, or present-but-empty, is an outage; anything
below a populated root is a broken path and fails.
And it read a listable directory as proof the photo was at fault, so
EACCES on a reconnected share, EIO, or the classic NFS ESTALE handle
were burnt as permanent failures. Only ENOENT now means genuinely gone;
any other error opening the file defers.
The event-wide backoff was also too broad. A reference event can hold
managed uploads alongside imported external ones, and those live in
local storage that is fine — excluding the whole event id left them
unscanned for as long as external rows kept renewing the cooldown, which
during a real outage is indefinitely. The exclusion is now scoped to
external and reference rows.
One of my own tests had modelled the unmount wrongly: it emptied the
photo's subdirectory rather than the event root, which under the
corrected logic is a populated mount with a missing folder — a failure,
not an outage. It now empties the root, which is what an unmount
actually leaves behind.
Dropped the path require the first version of this probe needed; the
event-root form does not.
All three fixes mutation-checked: reverting each one fails the test
written for it. Face suite 69 passed across 9 suites; full backend suite
fails the same 10 pre-existing suites as main, no more.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The all-in-one quickstart tells people to pull
`ghcr.io/picpeak/picpeak/aio:stable`, which has never been published, so
the documented one-liner fails for anyone who copies it:
docker: Error response from daemon: failed to resolve reference
"ghcr.io/picpeak/picpeak/aio:stable": not found
`merge-aio` does gate `:stable`/`:latest` on `refs/heads/stable` or a
non-prerelease `v*` tag, same as backend/frontend — but `Dockerfile.aio`
only landed on `main` in 0874a30a (#1068, 2026-08-18), and the current
`stable` head (3.46.1) does not contain it. So the aio image has only
ever built off `main`/beta refs, and its full tag list on GHCR and Docker
Hub is `main`, `beta`, and `3.10x.y-beta.0`. backend and frontend both
have `stable` and `latest`; aio is the only image that does not.
Point the quickstart at `:main`, which exists today, and note when
`:stable` will start working so this can flip back after the next stable
promotion.
picpeak/ml has an empty Hub overview and picpeak/aio has none at all,
while backend and frontend carry hand-written ones — so the two newest
images are the two with nothing on their registry page.
Adds .github/dockerhub/{aio,ml}.md as the source of those pages and a
dockerhub-descriptions job that pushes them on every main merge, so the
page cannot drift from the release it describes. backend/frontend stay
hand-maintained for now: capturing their current Hub text into files is
a prerequisite, not a side effect of this change.
README gains a registry table for all four images (both registries share
digests and tags), the org-move callout lists the full set, and the
feature list finally mentions People in this gallery, which shipped in
#1074 without a README line.
The aio image (#1042) shipped GHCR-only with a TODO to wire the Docker
Hub mirror once the Hub repo existed. backend, frontend and the ml
sidecar all publish to docker.io/picpeak/*; aio was the only image a
Docker Hub user could not pull.
merge-aio now follows merge-backend/merge-ml verbatim: DOCKERHUB_ENABLED
computed from the repository slug (so forks stay GHCR-only), a gated
Docker Hub login, docker.io/picpeak/aio added to the metadata images
list, and a Docker Hub manifest inspect. Tag scheme is untouched — the
same beta/main/stable/latest/semver tags land in both registries.
The build summary drops the "Docker Hub mirror pending" note and lists
the aio (and ml) Hub images when the mirror is active.
* fix(faces): scan external/reference photos instead of skipping them (#1090)
faceProcessor short-circuited every photo with source_origin 'external'
or 'reference' straight to 'skipped', before the sidecar was ever
contacted. On an external-media install that is the entire library — the
reporter's gallery sat at 0/3230 with every row skipped and no error, and
a rescan changed nothing.
The guard was correct when written: resolvePhotoStorageKey returns null
for anything outside managed storage, so ensurePreviewImage could not
build a preview and there was nothing to send. #1078 removed that
limitation one release earlier — ensurePreviewImage now reads externals
straight off the mount via resolvePhotoFilePath and writes the preview
into managed storage, so the key faceProcessor already fetches through
getStorage() is readable like any other. The guard outlived its reason.
Photos whose source is genuinely gone still return a null preview key
and land in the existing 'failed' branch, which is the honest outcome:
that is a broken photo, not an unsupported one. The blanket skip was
absorbing those too.
No migration or manual reset needed — enqueueEvent already re-queues
rows with face_status in (NULL, 'failed', 'skipped'), so previously
skipped photos get picked up on the next scan.
* fix(faces): queue external imports for scanning (#1090)
The other half of the same bug, found by external review — and my first
counter-argument against it was wrong.
Managed uploads are enqueued by photoProcessor, which writes face_status
'pending' once a photo is processed (photoProcessor.js:573, commented as
"the only correct place to enqueue"). External media never goes through
photoProcessor at all: adminExternalMedia inserts rows directly, leaving
face_status NULL.
faceQueue.claimNextPhoto only claims 'pending' (faceQueue.js:64), so an
import into an already-enabled event produced nothing until someone
pressed Re-scan. Lifting the skip guard alone made external photos
scannable but still not scanned — which looks like a complete fix right
up until you import a photo.
Resolved once per import rather than per file, since it is a per-event
setting and the loop can run to a thousand files, and guarded on both
the global flag and the per-event toggle exactly as photoProcessor
guards it, so installs without the feature still never write a
face_status. A failure to read the setting logs and imports anyway — the
photos are the point.
No video guard: walkDir only collects jpg/jpeg/png/webp, so nothing
faceProcessor would skip as video can arrive through this route.
* fix(faces): enqueue imports only after the event path is written
External review caught a race I introduced in the previous commit.
Marking rows 'pending' as they were inserted published claimable work
while events.external_path still held the old value — or none at all, on
a first import, since the route only writes it after the entire
thumbnail loop. The face worker polls continuously, so on any import
long enough to matter (the loop is ~100-300ms per photo, and the
reporter's library is 6500+) it would claim those rows, resolve them
against the wrong directory and mark them permanently 'failed' — a state
only an explicit Re-scan clears. That is strictly worse than the
unscanned photos this set out to fix.
Ids are now collected during the loop and marked pending in one pass
after the event path is written, chunked at 500 because SQLite caps a
statement at 999 bound parameters.
The test now drives the real route instead of re-implementing its logic,
and observes the mid-loop state from inside the per-photo thumbnail
call — the only hook that can see the window the race lived in. Verified
it discriminates: deleting the enqueue fails two tests, and moving it
back onto the insert fails the ordering test specifically.
* fix(faces): read the face setting after the import, not before
Third external-review round. The setting was captured before a loop that
runs for many minutes on a large library, so an admin who enabled
detection during an import left every photo imported after that moment
at NULL forever — the toggle endpoint only queues rows that already
existed when it fired.
Ids are now collected unconditionally and the setting is evaluated
immediately before the queue update, off a freshly read event row. The
guard is unchanged in substance: both the global flag and the per-event
toggle, so installs without the feature still never write a face_status.
Test flips the toggle from inside the mocked per-photo thumbnail call,
which is the same mid-loop hook the ordering test uses.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* test(ml): add an embedding-space fingerprint tool (#1084)
requirements.txt is pinned exactly so rebuilds produce byte-identical
embeddings, but nothing verified that. The API tests stub FacePipeline, so
a decode/resize/kernel change could move every stored cluster without
failing anything.
This prints a hash per stage — decode, resize, cvtColor, YuNet detect,
FaceNet forward pass — so the old and new image can be diffed on the same
host before any base-image or dependency bump lands.
Used it to answer the open question in #1084: Debian/Python 3.12 and
Wolfi/Python 3.14 produce identical hashes at every stage, so a base swap
would not invalidate stored clusters. The input is generated rather than
a fixture, and the hashes are deliberately not compared across
architectures — OpenCV and onnxruntime dispatch different SIMD kernels on
x86 and aarch64, so this answers "did this change move the numbers", not
"is every platform identical".
* test(ml): fingerprint the production path, not a parallel one
External review found the first cut was largely theatre.
The documented command could not run: tools/ is in .dockerignore and the
Dockerfile copies only app/, so the script is never inside the image. It
has to be mounted — which is what I actually did when producing the
numbers, while documenting something else.
Three stages were fingerprinting the wrong thing:
- The detector recorded "none" plus a return status, because synthetic
input has no face to find. It would have stayed green through any
change to YuNet or its kernels. Now the ONNX graph is driven directly,
so all twelve output heads always produce numbers, and the reported
thresholds are the service's (0.6/0.3) rather than FaceDetectorYN's
0.9 default.
- The embedding used a hand-rolled tensor, bypassing everything that
actually places a face in the embedding space: umeyama + warpAffine,
BGR->RGB, per-image standardization, layout, and the L2 normalization
the backend's cosine similarity depends on. It now calls _align and
_embed directly. Private, deliberately — reimplementing the maths here
would drift from pipeline.py and fingerprint a path nothing runs.
- Decode exercised PNG, but the worker only ever receives the preview
rendition, which imageProcessor.js writes as JPEG. Now a fixed JPEG,
embedded as bytes so the input cannot depend on the encoder version
being held still. Verified SOI/EOI-clean; the first attempt at this
produced "Corrupt JPEG data: 22 extraneous bytes".
Sensitivity checked rather than assumed: a one-pixel landmark nudge
moves align_warp and embed and leaves decode and the detector heads
alone, which is exactly the dependency structure expected.
Debian/Python 3.12 vs Wolfi/Python 3.14 remain identical across all
sixteen stages, so the #1084 parity conclusion still holds under the
stronger check.
* test(ml): measure the image's own pipeline, and the detector OpenCV runs
Two more from external review, both of which let matching hashes mean
less than they claimed.
The documented bind mount put the checkout's app/ ahead of the image's
/app/app, so comparing two images built from different revisions would
have executed the same pipeline source twice and reported a match no
matter how the images differed. /app now wins whenever it exists, so the
tool measures the image under test however it is invoked, and the loaded
path is printed as _app_source so that is auditable rather than assumed.
The detector was fingerprinted through onnxruntime, but production runs
cv2.FaceDetectorYN — OpenCV's own preprocessing, DNN engine and
NMS/landmark decode, none of which ORT touches. An OpenCV upgrade could
therefore move real landmarks, and with them alignment and embeddings,
while every detector hash held still. It now runs the OpenCV path too,
with the score threshold at the floor so synthetic input still yields
candidates (594 here) instead of the empty result the production 0.6
gives on an image with no face. The ORT pass is kept alongside it to
separate a model change from an OpenCV change.
Parity across debian/3.12 and wolfi/3.14 still holds across all 19
stages, and a one-pixel landmark nudge still moves align_warp and embed
and nothing else.
* test(ml): cover the orchestration and progressive decode too
Round three of external review found two more ways the hashes could
match while production moved.
The isolated stages never fed the detector's output into alignment —
_align got fixed landmarks — so INPUT_LONG_EDGE resizing and the row ->
landmark scaling in _one_face were invisible. process() now runs end to
end on the fixture, with the pipeline's own detector threshold dropped
so a faceless frame still yields rows to carry through (26 faces here).
A first attempt at that still missed the resize: the embedded fixture is
48px, so `long_edge > INPUT_LONG_EDGE` never fired and changing 1920 to
960 moved nothing. It now runs a second pass with the threshold lowered
under the fixture, which executes the same downscale and inverse
landmark scaling without carrying a 1920px image in the source. Verified
sensitive: moving that bound 32 -> 24 changes both the face count and
the embedding.
The fixture was also a baseline JPEG, while generatePreview writes
progressive (imageProcessor.js:236/480/617) — a different path through
libjpeg. Swapped for a progressive fixture, SOF2 confirmed present and
SOF0 absent.
24 stages now. Debian/3.12 and Wolfi/3.14 remain identical across all of
them.
* test(ml): close three more false-negative paths in the fingerprint
Round four of external review. All three let hashes match while
production moved.
INPUT_LONG_EDGE was used but never printed. The fixture is too small to
trip the resize in either image, and the forced pass overrides the value
in both, so a production change from 1920 to 960 moved no hash at all.
It is now emitted alongside the other thresholds, where a reviewer sees
it in the diff.
The fixture was square, so a width/height swap in setInputSize or the
resize produced identical dimensions and identical hashes. It is now
64x48.
The forced-downscale pass hashed only an embedding, which is derived
from separately scaled landmarks — a regression in the inverse scaling
of row[0:4] would have shown up nowhere, because the normal pass runs at
scale 1. That bbox is now hashed too; a wrong one is what breaks avatar
crops and area calculations.
Changing the fixture to 64x48 also broke the forced pass: at the old
bound of 32 the downscaled frame is 32x24 and YuNet returns nothing, so
the stage pinned nothing. The NO-DETECTIONS-STAGE-VACUOUS marker added
last round caught it immediately rather than printing a reassuring hash
of an empty result. Bound moved to 48, which still triggers the resize
and still yields rows.
25 stages, no vacuous markers. Debian/3.12 and Wolfi/3.14 identical
across all of them.
* test(ml): hash every detection, not just the first
Round five of external review. Both end-to-end passes hashed only
candidate 0, so a change that moved candidates 1..n — or merely
reordered them — matched as long as the count and the first candidate
held. With the threshold at the floor those passes return 24 and 27
candidates, so that was most of the evidence being thrown away.
Both now stack every returned face, in order, via a shared _hash_all.
Stacking preserves order, so a reshuffle is caught too.
Verified against the exact case: reversing candidates 1..n while leaving
the count and candidate 0 untouched now moves process_embedding and
process_bbox. Before this it moved nothing.
* test(ml): hash every persisted field, and emit the model version
Round six of external review, plus the adjacent gaps it implied.
Two findings: MODEL_VERSION was never emitted, and _hash_all discarded
score. Both matter to the backend rather than to the numbers — a
model_version change makes faceClustering.js:190 refuse to compare new
faces against existing people, forcing a rescan, and det_score decides
via meetsQualityFloor (faceClustering.js:96-100) whether a face joins
clustering at all. Either could change while every hash held still.
Rather than fix only the two named, I checked what faceProcessor.js
actually stores per face (:157-167) and covered all of it: bbox, score,
yaw, pitch, blur, embedding. yaw/pitch/blur were heading for the same
finding next round. One hash per field, so a diff says which thing moved
rather than only that something did.
model_version is emitted as a compatibility key alongside the
thresholds, not hashed — it is a string, and its job is to be read.
Verified: scaling score alone by 0.999 now moves process_score and
nothing else. 33 stages, no vacuous markers, debian/3.12 and wolfi/3.14
still identical.
* test(ml): split verdict from diagnostic, and stop masking the threshold
Round seven of external review.
The ORT detector hashes were being read as part of the compatibility
verdict, but production never runs YuNet through onnxruntime. An ORT
change touching a YuNet operator would have moved them while real
behaviour was untouched, and the docstring said any difference means
re-scan — so the tool could have ordered a full-gallery rescan for
nothing. They are now diag_-prefixed, and the docstring states which
keys carry a verdict, which are diagnostic, and which are metadata a
reviewer has to read rather than diff.
setScoreThreshold(1e-6) also overwrote the detector's real threshold
before anything recorded it, and _thresholds.det_score only echoes
config. If FacePipeline ever stopped applying DET_SCORE_THRESHOLD —
falling back to OpenCV's 0.9 default — production would detect a
different face set while every hash matched. The constructed value is
now read first and emitted as _effective_det_score; simulating the
regression makes it read 0.9 instead of 0.6.
MAX_FACES is emitted for the same reason INPUT_LONG_EDGE is: the fixture
never reaches the pipeline.py:138 slice, so 64 -> 128 would move no hash
while real group photos persisted a different face set.
21 verdict keys, 12 diagnostic, no vacuous markers, debian/3.12 and
wolfi/3.14 still identical across both sets.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(faces): restore the :beta image tag and surface sidecar health
Both halves of what a user hit on discussions/1069: the People card sat
at "Scanning… 0 of 227" for 30 minutes with no explanation, because the
sidecar container could never have started.
docker-build.yml — republish `:beta`. It used to come for free via
`type=ref,event=branch` when the active development branch was literally
named `beta`; the rename to `main` silently retired it. backend:beta has
been frozen at 2026-06-29 (448da950) ever since while :main moved on, so
PICPEAK_CHANNEL=beta has been serving a seven-week-old build across every
image. The ml sidecar was added after the rename and so never had a
`:beta` at all, which left docker-compose.production.yml:158 unable to
resolve ghcr.io/picpeak/picpeak/ml for any documented channel — the
image simply does not exist as :beta or :stable, only as :main and
pinned versions. Tag added to all four merge jobs, gated on main.
`:stable` stays absent for ml on purpose: it is gated on refs/heads/stable
and the sidecar does not exist there. stable's docker-compose.production.yml
carries no picpeak-ml service, so nothing can reference the missing tag.
FaceRecognitionCard — show when the sidecar is unreachable. An
unreachable sidecar is not an error by design: faceQueue.js:132-136
releases the photo back to `pending` and retries forever so a restart
does not burn the queue. The cost was that a stopped container looked
exactly like a slow scan, indefinitely, and the only signal was a
backend log line rate-limited to once per five minutes.
/admin/events/faces/health already existed and nothing in the frontend
called it. It is now polled while a scan is in progress, and a failing
check replaces the spinner with the sidecar URL, the underlying error
(which distinguishes a stopped container from a token mismatch) and the
command to start it.
Health is only polled while a scan is running — an idle card has no
reason to care whether the sidecar is up.
* fix(faces): tell the three sidecar failure modes apart
Follow-up to the health surface in this branch, from an external review
pass. The original warning was right about "the sidecar is not working"
and wrong about almost everything after that.
faceClient.checkHealth now returns a `reason` rather than only a message,
because the caller has to know whether photos survive:
- 'unauthorized' (401) and 'rejected' (any other 4xx) both become
SidecarRejectedError in classify(), which workerLoop does NOT retry —
every claimed photo is marked 'failed'. Telling the admin the scan
resumes on its own was simply untrue there; both now say to fix the
cause and Re-scan.
- 'unreachable' (refused/DNS/timeout/5xx) is the retryable one.
The card also no longer cries wolf. /faces runs inference synchronously
inside an `async def`, so one slow photo blocks the event loop and stalls
/info past its 5s timeout — a healthy sidecar can fail a probe. Verified
with an isolated uvicorn repro: a blocking call in an async handler
stalled the sync /info endpoint to 5.01s. The warning now needs three
consecutive failures AND no drop in `pending`. Three because a single
/faces call may legitimately run to FACE_ML_TIMEOUT_MS (30s) and two
probes 15s apart both fit inside that window; `pending` rather than
`scanned` because scanned counts only 'done', so a run producing
skipped/failed photos is progress that counter misses.
A 4xx burns the queue with no backoff, so it can empty before anyone
opens the card — in_progress goes false and only "227 failed" is left.
The probe therefore also runs when a finished scan has failures, and the
notice renders under the counts instead of replacing them. It is worded
as present-tense service state, not as a claim about those specific
failures: a live probe cannot know whether they came from this
misconfiguration or from corrupt images earlier. Attributing them exactly
needs stored face_error rows, which is a bigger change than this.
Also adds the missing-token case to the unreachable text: FACE_ML_TOKEN
has no default and the container refuses to start without it, so the most
likely first run fails as a plain connection refusal that "just start it"
does not fix.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Three Trivy cleanups off the code-scanning tab.
ml/Dockerfile — install no runtime apt packages at all. Neither libgl1
nor libglib2.0-0 is needed: opencv-python-headless 4.14 bundles what it
needs and `ldd .../cv2/cv2*.so` resolves fully on a bare slim base. The
old comment claimed the headless wheel still links libGL, which was
true of much older wheels. libgl1 was dragging in 36 transitive
packages (mesa, LLVM, X11) for a service that never opens a display.
Measured with `trivy image` on locally built variants:
before: 165 findings — 88 low / 49 med / 19 high / 6 crit
without libgl1: 133 findings — 58 low / 48 med / 19 high / 5 crit
without either: 123 findings — 57 low / 46 med / 13 high / 4 crit
42 findings gone, image 1.05GB -> 774MB. Not one of the 165 had an
upstream fix available, so not installing the packages is the only
lever there is.
docker-build.yml — set ignore-unfixed on all four Trivy steps. All 123
remaining ML findings are unfixed base-OS CVEs; Debian has them
resolved in sid and pending backport to trixie, and apt-get upgrade -y
behind CACHEBUST picks each one up automatically. Reporting them buries
anything actionable, and suppressing them is the precondition for ever
setting exit-code: 1.
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. Not reachable in our code:
html-to-text only feeds deepmerge-ts its options object
(html-to-text.mjs:1468, :1442), never parsed email content. npm audit
goes 3 high -> 0.
The lockfile also picks up the version field release-please had left at
3.103.1-beta.0, plus some "peer": true metadata npm 11.6 recomputes.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(preview): generate lightbox previews for external/reference photos (#1078)
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 ("LocalFsStorage: invalid relative path: null"),
so the preview route fell back to redirecting at the full-size original. A
gallery whose photos are all external got no benefit from the preview tier
(#492) at all: guests paid 5-12 MB on every lightbox open, with nothing
surfaced in the admin UI.
Add the external branch ensureThumbnail() has had since #423: resolve via
resolvePhotoFilePath() and feed the mount path to generatePreviewImage()
directly, with an ext<id>_ output basename so two events referencing the same
NAS filename can't clobber each other's preview.
Also close the adjacent hole that made the failure a throw rather than the
documented null: a row with no source_origin in a reference-mode event takes
its mode from the event, so resolvePhotoStorageKey returns null for it too.
Return null instead of handing that to withLocalCopy.
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
* style(test): single-quote the source-inspection needles
Matches the repo eslint quotes rule (no avoidEscape) by dropping the nested
quotes from the search strings rather than escaping them.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(ml): optional face-detection sidecar, opt-in and inert by default (#1074)
First of four PRs for "People in this gallery". This one ships only the
sidecar, its wiring and its CI — no schema, no backend code, no UI. Nothing
in PicPeak calls it yet.
picpeak-ml is a single FastAPI + onnxruntime container: three endpoints
(/health, /info, /faces), no database, no volumes, no egress, no model
download at runtime. Clustering, person identity and every privacy decision
stay in the backend where the data already lives.
Models are YuNet (detection) + FaceNet-512 (embedding), both MIT, both
pinned by URL and SHA-256 and verified at build time. The licence analysis
is in ml/LICENSES.md: the more accurate InsightFace weights are
non-commercial-only and PicPeak's users are working photographers, so they
are never baked into an image we publish.
Two things worth review attention:
- Alignment uses a least-squares similarity transform (Umeyama), NOT
cv2.estimateAffinePartial2D. RANSAC and LMEDS exist to reject outliers
among many correspondences; given five landmarks and no outliers they fit
a three-point subset exactly and let the rest drift. Measured on a real
off-frontal portrait: eyes and nose pinned to 0.11px, mouth corners
11.8px out on a 160px crop. Umeyama distributes it (max 6.5px, rms 5.1 vs
7.4). The failure mode is silent — a bad warp still yields 512 confident
floats — so tests/test_pipeline.py pins it numerically.
- FACENET_ONNX_URL has no default and the build fails loudly without it.
deepface distributes FaceNet-512 as Keras .h5 only, so the ONNX is
produced once by tools/convert_facenet.py and published as a release
asset. Converting inside the build would drag TensorFlow through both
architecture legs of every build to produce a byte-identical file. The CI
jobs are gated on the FACENET_ONNX_URL repository variable and skip
cleanly until it is set.
Off by default, twice over: the sidecar is behind the `faces` compose
profile, and the backend will gate on a `faces` feature flag that defaults
to false. FACE_ML_URL defaults to http://picpeak-ml:8000 so the standard
deployment needs no configuration — nothing dials that host while the flag
is off, which is why a non-resolving default is harmless.
Verified: 27 pytest tests green; YuNet loads and detects against a real
portrait with its landmark order matching the alignment template
index-for-index; both compose files validate and the faces profile is
correctly excluded from a default `up`; workflow YAML parses and the job
graph resolves.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(ml): pin the converter toolchain, verify parity, drop a false reproducibility claim (#1074)
Ran the FaceNet-512 conversion for real and corrected what the previous
commit assumed about it.
The conversion works: 23,497,424 parameters, 89.6 MB ONNX, and the converted
graph matches the Keras original to 2.086e-06 absolute / cosine
1.0000000000. That check is now part of the script rather than something I
did once by hand — a subtly wrong graph still returns 512 plausible floats,
so it refuses to leave the file on disk if parity fails.
Also ran the full pipeline against both real models end to end. The
embedding is L2-normalized to 1.000000, and the same face survives being
re-rendered: half scale 0.973, double scale 0.984, JPEG q40 0.987, rotated
8 degrees 0.984, brightness +40 0.988. Scale invariance in particular is
evidence the alignment warp is doing its job.
Corrected claim: the conversion is NOT byte-reproducible. Two runs with the
same pinned versions on the same machine gave different SHA-256s. The graphs
are functionally identical — same 336 nodes, same 271 initializers, every
weight matching to 0.000e+00 — but a few initializer names differ because
tf2onnx's traced-op naming is not deterministic (Keras layer naming is
deterministic; I checked). The previous commit message and README both
claimed byte-identical output. They were wrong, and it matters: anyone
re-running the conversion gets a different hash, and without this note that
reads like tampering. The build-time SHA-256 pins one published artifact so
its URL cannot start serving different bytes; validating a fresh conversion
is the parity check's job.
requirements-convert.txt now pins the exact set that produced the artifact,
including transitive keras/protobuf/numpy, and documents that the converter
needs Python 3.11 while the image runs 3.12.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* feat(faces): schema, queue, clustering and API for People in this gallery (#1074)
Backend half of the feature. Migration 177, a face-detection queue, the
clustering engine, the gallery and admin APIs, and the privacy wiring. No UI
yet; nothing is reachable until the `faces` feature flag is on, which
defaults to false.
The flag is the gate, not FACE_ML_URL. That variable now has a working
default (the compose service name), so its presence proves nothing about
intent — if it were the gate, every install would poll a hostname that does
not resolve. faceQueue re-checks the flag every tick, so turning it off stops
the workers without a restart.
Visibility scoping is the part worth reviewing closely. Face rows have no
concept of photo visibility, but guests are restricted to
photos.visibility='visible'. A raw count leaks how many hidden photos someone
appears in, and an unscoped cover face renders a crop of a photo the guest
may not open — with the best-scoring face being the likeliest pick, so it
would happen often rather than rarely. facePeopleService recomputes both per
request against the caller's own scope, and event_people.face_count_total is
named to be conspicuous in a guest path. Six tests cover it, including the
case where a person's photos are ALL hidden and they must vanish entirely.
Face data is excluded from backups and .picpeak exports, per the decision in
the thread: it is derived, so a restore re-scans rather than carrying
biometrics between operators. Three separate mechanisms, because the engines
cannot be filtered alike — EXCLUDED_TABLES for export, --exclude-table-data
(not --exclude-table; the CREATE TABLE must survive or restore breaks on the
first query) for Postgres, and DELETE + VACUUM on the temp copy for SQLite,
which has no way to exclude a table from a whole-file .backup. The VACUUM is
not cosmetic: without it the pages stay in the file and the claim is false on
disk.
Archiving now purges face data explicitly. photo_faces cascades off photos,
but archive deletes neither the photo rows nor the event, so without this an
archived gallery kept its biometrics indefinitely.
Other decisions: clustering keeps names across a re-cluster by majority
inheritance (without it, one button click silently discards every name the
photographer typed); consolidation refuses to merge two people who were named
differently; assignment never compares across model_version, since embeddings
from two pipelines are not comparable; low-quality faces are stored but left
unassigned so they show in "this photo contains" without spawning junk people.
Migration is 177, not 174 — 174/175/176 landed on main while this branch was
open.
29 tests green: 7 migration (idempotency, down(), cascade, and that
installing it enqueues NOTHING), 11 clustering, 11 privacy/visibility. Lint
clean; the pre-existing error counts in databaseBackup.js and server.js are
unchanged.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* feat(faces): People strip, face filter and admin controls (#1074)
Frontend half. Renders nothing anywhere unless the `faces` feature flag is
on AND the photographer enabled detection for the gallery — the whole guest
surface hangs off one boolean, event.people_enabled, which the server
computes from the flag, the per-event toggle and the show-to-guests toggle
together.
Guest side: a People strip between the filter bar and the grid, circular
crops from each person's cover face, an active-filter chip row, and a "Show
all" bottom sheet. The face filter composes with category, search, media
type and the liked/saved/rated filters in the same useMemo rather than
replacing them, so "photos of Anna that I liked" works. Two people selected
means AND by default — that is what picking a second face almost always
asks for — with a toggle to OR that appears only once a second person is
picked.
Unnamed people show a photo count and never "Person 7". A number is honest
about what the system knows; an invented name is not. There is a test
asserting we don't do it.
The strip renders nothing below two people, collapses to one line when
dismissed (persisted per slug, so dismissing one gallery says nothing about
the next), and appears mid-backfill with a progress line rather than
blocking the gallery behind a spinner. Avatar crops are computed in ratios
of the source dimensions so they survive whatever rendition the browser
gets; without width/height they fall back to an uncropped thumbnail, since
a wrongly-offset crop is worse than no crop.
No new download endpoint: "download these N" rides the existing photoIds
path, which already enforces access level and per-category permissions
server-side. Adding a person_id selector would have been a second thing to
authorize for no gain.
Guest-facing copy never says "biometric" or "recognition" — those words
describe our implementation, not the guest's experience. The sheet's
footnote answers the first question every guest has (where does this go?)
inline. The admin card, by contrast, is explicit: it states the controller
obligation next to the toggle, and warns that scanning materializes the
preview tier on galleries that never generated one, which is real CPU and
disk an admin should know about before a 2,000-photo backfill.
EN + DE translations. 140 frontend tests green (8 new), tsc and eslint clean.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(faces): measured match threshold, working build defaults, 89MB smaller image (#1074)
Ran the Phase 0 spike that had been outstanding, published the model, and
fixed what both turned up.
THRESHOLD IS NOW MEASURED, NOT GUESSED. LFW's standard 1000-pair protocol
run through this exact pipeline (YuNet -> Umeyama alignment -> FaceNet-512
ONNX), 100% detection on 2000 images:
same person cosine 0.6958 +/- 0.1415
diff person cosine 0.0849 +/- 0.1674 separation 0.6109
peak accuracy 96.60% @ 0.405
So the pipeline separates people well — the thing I could not previously
claim, since every earlier number was the same face re-rendered.
Default moves 0.62 -> 0.50. The old value was a placeholder and a bad one:
it gave 0% false merges but 22.4% false splits, i.e. roughly one in four
same-person pairs failing to join, which fragments a gallery badly. 0.50
gives 1.0% false merge / 8.2% false split. Peak accuracy (0.405) is
deliberately NOT chosen: for clustering the two errors do not cost the same.
A false split is a duplicate row the photographer can merge away; a false
merge puts a stranger into someone's "download my photos" — and until the
Phase 2 merge/split UI ships, there is no way to undo one. So this sits on
the conservative side of the optimum.
The spike is committed as ml/tools/benchmark_threshold.py rather than
thrown away, so "why 0.50?" has an answer in six months and a re-tune is one
command.
BUILD DEFAULTS. FACENET_ONNX_URL/_SHA256 now default to the published
ml-models-v1 release asset, so `docker build ml/` and
`docker compose --profile faces up` work with no arguments. Blanking either
still fails loudly — a URL without a checksum is never acceptable, since the
checksum is what makes the URL safe to trust. Found by running compose for
real: it failed exactly as designed, which was correct behaviour and a bad
out-of-box experience now that a canonical artifact exists.
IMAGE SIZE. 389MB -> 300MB single-arch. `chown -R` after COPY rewrote every
copied file into a fresh layer, duplicating the 90MB model for nothing; the
user is now created before the copies and ownership set via COPY --chown.
Also drops pip/setuptools from the runtime image. Measured RSS is 186MiB
idle, and the container answers /faces end-to-end in well under the
80-150ms/photo the issue budgeted.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(faces): threshold 0.50 -> 0.60 from real clustering, theme-aware People strip (#1074)
Both fixes come from running the feature on an actual gallery — 61 photos,
5 real identities — rather than reasoning about it.
THRESHOLD. The LFW pairwise sweep in the previous commit said 0.50, and it
was wrong. On a real gallery at 0.50, three of six visible clusters were
contaminated: two different people merged into one strip entry, which is the
exact failure that puts a stranger into someone's "download my photos".
Pairwise error rates do not predict cluster purity. Greedy assignment
compounds — one wrong face drags the centroid toward the midpoint between two
identities, making the next wrong face likelier. A 1% pairwise false-merge
rate is not a 1% chance of a clean gallery, and no amount of staring at an
ROC curve would have shown that.
Sweep against ground truth (5 identities):
0.50 -> 6 clusters, 3 contaminated
0.56 -> 6 clusters, 0 contaminated
0.60 -> 5 clusters, 0 contaminated <- exactly right
0.64 -> 5 clusters, 0 contaminated, fewer faces assigned
0.60 recovers the right number of people with no contamination; higher only
loses coverage. Migration 177 carries the full reasoning so the next person
to touch this knows why the obvious pairwise answer is the wrong one.
THEME. The People strip hardcoded `text-neutral-800` for named people. On a
dark gallery — which the screenshot immediately showed — that renders a
named person's label almost invisibly, while UNNAMED people stayed legible.
Exactly backwards. Labels, headings, the collapsed summary, the scan line
and the filter chip row now read the gallery's own theme tokens
(--color-text / --color-muted-text / --color-accent / --color-surface-border)
like the rest of the gallery surface.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(faces): keep the mobile filter row inside the viewport (#1074)
At 390px the photo count and Clear link were pushed against the right edge
by ml-auto and clipped. Only apply it from the sm breakpoint up, where
there is room; below that they flow after the chips.
Found by screenshotting the real thing on an iPhone-sized viewport.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* feat(faces): complete Phase 1, add People management and auto-categories (#1074)
Closes the two Phase 1 gaps, then builds Phase 2 and Phase 3.
PHASE 1 GAPS. "Download these N" was specified, described as done in an
earlier summary, and never actually built — I had verified the backend needed
no new endpoint and let that stand as if the button existed. It now hands the
filtered photo ids to the same path as a manual selection, so the server
re-applies access level and per-category permissions on the way through.
Photos in a downloads-disabled category are excluded client-side too, so the
number on the button is the number the guest receives. Hidden entirely when
downloads are off for the gallery.
Lightbox person chips ("In this photo: Anna") are the second way into the
face filter — a guest looking at a photo of themselves can act on it without
scrolling back to the strip. Tapping one closes the lightbox and filters the
grid behind it.
PHASE 2. A People management modal over the endpoints that already existed
and were already tested: rename inline, merge (multi-select, first pick is
the target so the name a photographer typed survives), split via a face
picker, hide, ignore. This matters more than it sounds — clustering
deliberately errs toward splitting because a wrong merge puts a stranger into
someone's download, and that trade only works if merging is easy.
PHASE 3. Rule engine over face_count plus face-area ratio: 0 -> Details,
1 large -> Portraits, 2-5 -> Small groups, >5 -> Groups. The area ratio is
what separates "a portrait of someone" from "someone is in this landscape".
Three guarantees, all tested: it only ever fills an EMPTY category (enforced
in the query AND re-checked in the UPDATE, so a photographer setting one
mid-run still wins), everything it touches is marked auto_categorized so undo
is exact, and it is a no-op unless separately enabled. Migration 178 adds the
column — separate from 177, which has already run wherever this branch is
deployed.
Verified on the real gallery: 61 photos -> 48 portraits + 13 small groups,
undo cleared exactly 61 and left the manual ones alone. Merge moved faces and
removed the source. Both confirmed against the database, not just the UI.
TWO BUGS THE BROWSER CAUGHT, both invisible to tsc:
- The lightbox destructure never landed — my patch targeted a line that has a
default value, matched nothing, and failed silently. `people` resolved to
something else entirely and the chips would never have rendered. eslint's
"outer scope value" warning is what surfaced it.
- Admin face thumbnails 403'd because <AuthenticatedImage> attaches whatever
gallery token is in session storage; an admin who has also opened one of
their own galleries sends a type:"gallery" bearer to an admin route. Admin
routes authenticate from the httpOnly cookie, which a plain same-origin
<img> sends by itself. Worth noting AdminPhotoGrid has the same latent
shape; not touched here.
Also: the admin card now reports "N people (M shown to guests)" when those
differ, so the settings page and the gallery stop disagreeing without
explanation.
45 backend tests (8 new) and 140 frontend tests green; tsc and eslint clean.
EN + DE for every new string.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* perf(faces): batch migration DDL and drop the face stack from server.js import (#1074)
CI's backend job timed out at 10 minutes on the first run of this branch.
Nothing failed — 132 of 182 suites passed and the wall clock ran out. Main
does the same 182 in 124s, and where main has 12 suites slow enough for jest
to print a duration, this branch had 77.
Two changes, both worth making regardless of how much of the gap they close:
- Migration 177 added its columns one ALTER TABLE at a time (four on photos,
three on events, plus a separate index statement) and seeded settings with
a SELECT and an INSERT per key. It now uses one alterTable per table and
one SELECT plus one bulk INSERT. 178 folds its index into the same
statement as its column. That chain replays in ~90 suites, so statement
count there is multiplied by 90.
- server.js required faceQueue at module scope, which pulls in axios and —
through imageProcessor — sharp. Every supertest suite that imports
server.js was paying for a module graph it never uses. Now required inside
the startup block, next to the call that needs it.
Honest about the evidence: locally the migration delta measures at zero
(1.15s vs 1.13s for the same suite, three runs each), so batching alone does
not explain an eight-minute regression. A fast local disk and many cores mask
per-statement and per-import costs that a two-core runner with a shared disk
does not. These are the two real costs this branch added to a path that runs
in almost every suite; whether they are sufficient is a question for CI, not
for another round of local speculation.
37 face tests still green after the change.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* i18n(faces): complete EN and DE coverage for the face feature (#1074)
The admin card and the Features toggle were rendering entirely from inline
English `defaultValue` fallbacks — 22 keys existed in no locale file at all,
so a German admin saw an English consent notice, English toggles and English
buttons. The gallery side was already translated; the admin side was not,
and nothing in the toolchain flags this because a `defaultValue` always
renders something.
Adds the missing `admin.faces.*` (19), `settings.features.faces.*` (2) and
shared `common.clear/saved/saveFailed` in both languages. Existing keys are
left alone (setdefault, not overwrite), so the shared `common` strings other
features rely on are untouched.
Committed the audit as frontend/scripts/i18n-faces-audit.py rather than
throwing it away: it extracts every t() key the face components actually use
and diffs it against each locale, and it also reports German values that are
byte-identical to English, which is the usual shape of an untranslated
copy-paste. Currently: 69 keys in use, EN complete, DE complete, no
identical pairs.
Verified in the browser, not just in the JSON — the German card reads
"61 / 61 Fotos durchsucht · 16 Personen (5 für Gäste sichtbar)" end to end.
Also checked the components for hardcoded user-facing text (JSX nodes,
title/aria-label/placeholder attributes) outside t(); there is none.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(faces): 13 defects from external review — coordinates, counts, erasure, races (#1074)
Codex reviewed the branch against main. Thirteen findings, nine P1. I checked
every one against the code and could not dismiss a single one as a false
positive, so all thirteen are fixed here.
THE WORST ONE: bounding boxes were stored in the wrong coordinate system.
The sidecar reports coordinates in the space of the image it was HANDED —
which is the ≤1920px preview, not the original — while every consumer
compares them against photos.width/height, the original dimensions. A 6000px
photo therefore produced boxes ~3x too small and areas ~9x too small: avatar
crops landed in the wrong place and the Portraits rule could never fire. It
is invisible on any photo already under 1920px, which is exactly why the
demo gallery and every screenshot looked correct. Now scaled once in
faceProcessor so everything downstream can assume original-image coordinates.
ERASURE. The FK cascade on photo_faces is decorative on SQLite: PicPeak never
enables `PRAGMA foreign_keys`, so deleting a photo left its embeddings
behind. I first enabled the pragma globally and reverted it — six unrelated
suites immediately failed on pre-existing dangling references, and switching
it on would start rejecting inserts on every existing install. That is a real
change worth making, but it is its own PR, not a rider on this one. Instead
deletion purges explicitly: purgePhotoFaces in the photo paths (single, bulk,
service) and photo_faces/event_people in deleteEventCascade. Tests assert
this with the pragma explicitly OFF, so they can only pass if the code does
the work.
COUNTS. A re-scan deleted the old face rows without undoing their
contribution to event_people, so counts inflated on every re-scan and ghost
people survived. Now the affected people are recomputed before the
replacements are assigned. My own "must not double its faces" test only
checked photo_faces rows, which is why it passed throughout.
RACES. A worker that finished after an admin purged the event committed its
rows anyway — erasure reported success and the data reappeared. The commit is
now conditional on the row still being 'processing'. And assignFaces is
read-modify-write over an event's people, so two workers lost each other's
updates; it is now serialised per event with an in-process mutex plus a
Postgres advisory lock for the multi-pod case the queue advertises.
METADATA LOSS. Merging discarded the source's name and suppression flags, so
a merge could erase a typed name or un-hide someone. Reclustering remembered
only people with a label, so an unnamed-but-hidden bystander came back
guest-visible after one "Re-group people" — and suppression now propagates to
every descendant cluster, not just the majority one.
Also: export reset face_status so a restored gallery re-scans instead of
claiming to be scanned forever; manual category edits clear auto_categorized
so "undo automatic" cannot delete a photographer's own choice; external
photos are skipped rather than failed (resolvePhotoStorageKey returns null
for them by design); the gallery refetches photo memberships as a scan
progresses so filtering is not stale; a failed VACUUM now fails the backup
rather than publishing one that may retain biometric pages; and the ML
Dockerfile's `|| true` is scoped to the uninstall — as written it was
`(install && uninstall) || true`, so a failed dependency install produced a
green layer and an image with no onnxruntime.
Four new regression tests. Full backend suite failure set verified identical
to origin/main; frontend 140 green; tsc and eslint clean.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(faces): 12 more defects from review round 2 — cross-event purge, leaks, lifecycle (#1074)
Second Codex round on the same diff, now including round 1's fixes. Twelve
findings, seven P1. Again none were false positives.
SECURITY, AND MINE FROM ROUND 1: the bulk-delete face purge iterated the raw
`photoIds` from the request instead of the event-scoped `photos` rows the
handler had already validated. purgePhotoFaces has no event scope of its own,
so an editor could pass another gallery's photo id and delete its face data —
even though the photo deletion right below it was correctly scoped. Fixing
one thing and introducing another is exactly why the second round was worth
running.
ANOTHER VISIBILITY LEAK, same class as the one round 1 fixed: /people returns
scan progress, and getScanStatus counted every photo with a face_status —
including hidden ones. Guests could read the hidden-photo count off the
progress bar while the people list and covers beside it were properly scoped.
Now scoped by the same predicate, with the caller passing its audience.
RECLUSTER, ROUND 1'S FIX WAS INCOMPLETE. I made suppression follow every
descendant but still copied the flags from the majority ANCESTOR. When
reclustering merges a visible named person with a hidden one, the majority
ancestor is often the visible one — republishing the hidden person's photos.
Suppression is now OR-ed across every ancestor contributing faces. The name
also now goes to the genuine largest descendant; the previous code took
whichever cluster came first in map order, which the comment already claimed
it did not.
LIFECYCLE. Face data is excluded from backups and exports, but photos.
face_status came across intact, so a restored install claimed every photo was
scanned while holding no faces — and the worker only claims 'pending', so it
stayed that way forever. Now: the SQLite backup requeues in the dump, restore
requeues after the pool reinit (the Postgres path cannot rewrite rows inside
pg_dump), the portable importer purges LOCAL face tables (they were excluded
from the replace list, so another instance's embeddings survived an import
with FK checks suspended) and requeues, and archiving disables detection so a
restored archive is honestly off rather than enabled-and-empty.
WRITE PATHS. Only processPhoto enqueued. The synchronous upload path
(chunked-upload completion, watch-folder) left photos unscanned, and
replacePhoto kept the OLD image's faces on a row now pointing at a different
picture — stale identities shown on the new photo.
FRONTEND. PeopleSheet and the admin manager rendered centred thumbnails and
ignored the bbox, so on group photos the avatar showed whoever stood in the
middle and two people from one photo were indistinguishable — in the manager
whose entire job is telling faces apart. The crop maths is now one shared
helper (faceCrop.ts) so the three surfaces cannot drift again. Full-page
layouts (gallery-premium, gallery-story) render their own lightbox and never
received the people props.
Backend failure set verified identical to origin/main; frontend 140 green;
tsc and eslint clean.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(faces): round 3 — five of round 2's fixes were wrong or no-ops (#1074)
Third and final Codex round. Eight findings, four P1 — and the important part
is that FIVE of them are defects in round 2's fixes, not in the original code.
- The sync-upload enqueue I added was a silent no-op. It queried through
`trx` after the transaction had already been committed, which throws
"Transaction query already complete" straight into the catch I had wrapped
it in. Chunked uploads and watch-folder imports were still never scanned,
and the code read as though they were. Uses `db` now.
- The post-restore requeue ran BEFORE the files were restored, in both the
portable importer and the native restore. The face worker is live during a
restore, so it could claim those rows and scan the previous instance's
files, or fail them for originals not yet on disk — with nothing to requeue
them afterwards. Both now run after file restoration; the native one is
extracted into requeueFaceScans() and called from the full and
database-only paths.
- The admin face crop mixed coordinate spaces: an original-pixel bbox scaled
against the THUMBNAIL's natural size. The API now returns the source
dimensions alongside the box, so there is one space to reason about.
- Forwarding people props through layoutProps did not make them work — the
full-page layouts never destructured them. GalleryStoryLayout now threads
them to its own lightbox.
Genuinely new findings, all in the same class as ones already fixed:
- releaseToPending updated unconditionally, so a photo purged while its
sidecar request was in flight came back as 'pending' and was rescanned —
biometric rows reappearing after the purge reported success. Round 2 fixed
exactly this on the COMMIT path and I did not carry it to the retry path.
Now guarded on 'processing'.
- purgePhotoFaces left face_status alone, so a worker mid-scan still
satisfied its commit guard and could write fresh faces into a photo being
deleted — orphans, since the FK cascade is inert on SQLite. It now clears
the claim as part of the purge.
- Phase 3 was unreachable: the migration seeds face_auto_categorize_enabled
false and nothing could ever write it, so the rule engine and its undo
endpoint returned "disabled" in every real flow. Added GET/PUT and a toggle
on the admin card, EN + DE.
NOT fixed, deliberately: GalleryPremiumLayout uses yet-another-react-lightbox
rather than the shared PhotoLightbox, so person chips there are a real port
rather than a prop forward. Recorded as open rather than bodged.
Backend failure set identical to origin/main; 41 face tests and 140 frontend
tests green; i18n audit reports EN and DE complete at 71 keys.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* feat(faces): block face recognition on the all-in-one image (#1074, #1042)
The single-container image cannot run this feature, so it is refused there
rather than left to degrade.
WHY, since the reason is not obvious from the code: the AIO image runs the
backend, the frontend, SQLite and every background worker inside one
container aimed at "one photographer plus guests browsing". It has no Redis,
SQLite gives it a single writer, and it contains no ML sidecar to talk to.
Face detection would add a second image-processing pipeline competing with
Sharp for the same CPU and memory. That failure is not loud — the install
just becomes slow and looks broken, which is the worst possible shape for a
deployment whose whole promise is one container and no decisions.
Gated on an explicit PICPEAK_SINGLE_CONTAINER marker, NOT inferred from
SERVE_FRONTEND or a SQLite path: plenty of legitimate multi-container setups
serve the frontend from the backend or run SQLite, and none of them should
lose the feature by accident.
Three layers, because the first is the only one that enforces:
- faceSettings.isFeatureEnabled() returns false before consulting the flag,
so a database restored from a full deployment with `faces` enabled still
cannot switch it on here.
- The feature-flag API forces `faces: false` in both directions, so the admin
UI reflects reality instead of offering a switch that refuses to stay on.
- The Features tab renders the card disabled with a plain-language reason,
read from a new `single_container` field on /admin/system/version (an
endpoint the admin UI already calls).
Documented in ml/README.md and .env.example. Three tests pin the behaviour,
including that the marker only accepts explicit truthy values.
NOTE FOR PR #1068: this expects `Dockerfile.aio` to set
`ENV PICPEAK_SINGLE_CONTAINER=true`. That one line lives on that branch and
is not in this commit — until it lands, an AIO build would still offer the
feature. Worth adding alongside the `Limits` section of docs/single-container.md.
44 face tests green; EN + DE complete at 72 keys.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* test(faces): pin the bbox coordinate space with a real scale factor (#1074)
The coordinate-space bug — boxes stored in preview space while every consumer
reads them as original-image pixels — had no test, and could not have been
caught by the ones that existed: every photo in the demo gallery is 750px, so
the scale factor was always exactly 1.0 and the correction never executed.
Verified by hand first, on a real 4000x3000 upload with the face placed
off-centre so a wrong crop would be unmistakable. Before the fix the stored
box was 1493,204 (preview space, face actually at x≈2850-3618); after, 3110,426
— a factor of 2.083, exactly 4000/1920, landing inside the face. The admin
crop then resolved to left=-395px/top=-46px on a 64px window, which is the
face centred.
That verification is now a test rather than a memory. Three cases: a 4000px
photo must scale by 4000/1920, a 1920px photo must NOT change (the case that
hid the bug), and a row with no width must fall back to unscaled rather than
storing NaN.
Note for anyone extending these: jest hoists mock factories above the file,
so anything they close over has to be `mock`-prefixed. Getting that wrong
fails at transform time with a message that does not name the variable.
47 face tests green.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(docker): add all-in-one image — backend + frontend in one container (#1042)
One container, one Node process, SQLite by default: `docker run` with no
compose file, no nginx, no supervisor, no bundled Postgres/Redis.
- Dockerfile.aio (repo-root context): frontend build stage + backend deps
stage + a runtime stage mirroring backend/Dockerfile's production stage,
with the built SPA copied to /app/frontend/dist and SERVE_FRONTEND=true.
DATABASE_CLIENT=sqlite3 and STORAGE_PATH=/app/storage are pinned
explicitly — the storage fallback resolves to container-root /storage,
which EACCESes after the su-exec drop.
- server.js: the SERVE_FRONTEND block now does what the nginx image did —
renders ${BRAND_TITLE}/${BRAND_DESCRIPTION} into index.html once at boot,
serves that rendered shell on /index.html and every SPA route, caches
hashed /assets/* immutably while the shell revalidates, and gzips the
bundle via compression() mounted after all /api routers. express.static
now runs with index:false so `/` keeps flowing to handlePublicSiteRequest
— its default index option was shadowing the landing page on native
installs.
- wait-for-db.sh: skip the Postgres readiness wait when DATABASE_CLIENT is
sqlite3. The engine resolver still runs, still logs, and still refuses
the populated-both conflict (#1038).
- .dockerignore: **/node_modules, so the root-context build can't pick up
host deps from backend/ or frontend/.
- docker-build.yml: build-aio / merge-aio follow the same per-arch build →
digest-merge → per-version tag scheme as backend/frontend (GHCR only for
now; the Docker Hub mirror is wired once the Hub repo exists), plus a
smoke-aio job that boots the image on every PR and asserts /health, the
SPA shell, the rendered brand title, immutable asset caching and the
SQLite engine resolution.
Pointing DB_HOST/DB_USER/DB_PASSWORD + DATABASE_CLIENT=pg at an external
Postgres works exactly like the backend image.
* fix(ci): correct three smoke-aio assertions that would fail a green image (#1042)
Found by running the smoke job locally against a real build — the image
passed every behavioral check, but three assertions were wrong:
- `/` asserts 200, but handlePublicSiteRequest 302s to /admin/login while
the public landing site is disabled, which is the state of the fresh
install the smoke container always is. Assert the redirect target
instead — that still proves express.static's index option is not
shadowing the handler, which is the thing the check exists for.
- The placeholder-leak grep matched index.html's explanatory comment,
which mentions BRAND_TITLE in prose and survives into the built shell.
Match the literal ${BRAND_TITLE}/${BRAND_DESCRIPTION} tokens with -F,
and cover the description token too.
- Add a gzip assertion, probing with GET: the compression middleware
skips bodyless responses, so a HEAD probe reports no Content-Encoding
even when compression is active.
Verified locally on linux/arm64: image builds clean, boots to healthy in
~8s on the SQLite default, and 25/25 checks pass (SPA shell, rendered
brand title, immutable+gzipped assets, no-store shell, SPA fallbacks,
npm removed, su-exec drop to nodejs, no errors in the boot log). The
DATABASE_CLIENT=pg override was exercised against a real Postgres too —
the readiness wait still runs and the engine resolves to postgres.
* fix(server): serve the SPA for every client route, not just /admin and /gallery (#1042)
nginx did `try_files $uri $uri/ /index.html`, so behind compose every
client-side route survived a direct hit or a refresh and the short
`['/admin', '/admin/*', '/gallery/*']` list was never exercised. Without
nginx that list is the whole contract, and everything outside it 404'd:
/setup /customer /impressum /datenschutz /payment-check
/quote/:token /contract/:token /invite/:token
/transfer/:token /transfer-upload/:token
/setup is the first URL a new install visits, so the all-in-one image was
unusable from a cold start.
The catch-all is registered after `app.use('/api', notFoundHandler)`, so
an unknown /api route still answers JSON instead of being handed the HTML
shell, and after the /s/:shortSlug resolver, so a typo'd short URL still
404s (#699). It is GET-only — a stray POST keeps 404ing rather than
getting a 200 page back. The handler is hoisted out of the
SERVE_FRONTEND block via `spaCatchAll` because that block runs before the
API 404 handler is registered.
Verified on the built image: all ten routes above now 200, /api/nope still
returns JSON 404, /s/nonexistent still returns 404, / still 302s to
/admin/login, and the smoke suite is 25/25. Both boundaries are now
asserted in the smoke-aio job.
* docs(readme): document the single-container install (#1042)
The README had no mention of the all-in-one image, so the only way to
discover it was reading the workflow file. Adds a Quick Start subsection
with the one-line `docker run` and the `docker exec … cat SETUP_TOKEN`
step, plus a row in the documentation table.
Deliberately does not sell it as the default: the note says the compose
stack is still the right choice for anything busier, gives the reason
(SQLite takes one writer at a time), and points at the `.picpeak`
restore as the way out, so nobody picks it and then finds themselves
stuck. Full details live at docs.picpeak.app/deployment/single-container
(PicPeak/docs#8).
* feat(docker): fold #1067's items into the all-in-one image (#1042)
Consolidating the two parallel AIO branches into this one. This PR's approach
is kept wherever the two differed on design — in particular the in-process
brand render, `index: false` (which fixes express.static shadowing
handlePublicSiteRequest, a bug #1067 had), the compression middleware, and the
smoke-aio job. What follows is what #1067 had that this branch did not.
Layout — the issue asks for a single mountable root, and this moves to one:
/data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN
/data/storage originals, thumbnails, archives
/data/logs application logs
/data/backup built-in backup output; /backup symlinks here
`-v picpeak:/data` and nothing else to remember. README and the smoke job's
database-path assertion follow the new layout.
Correctness items:
- sqlite CLI. DatabaseBackupService SPAWNS `sqlite3` for `.backup` and
PRAGMA integrity_check; the npm module does not ship that binary.
backend/Dockerfile omits it because compose always runs Postgres — this
image defaults to SQLite, so every database backup failed with ENOENT.
- /backup wired in. Migrations 029 + 030 seed /backup/picpeak and
/backup/database as the backup destinations; nothing created or mounted them,
so backups had nowhere to write and anything written would die with the
container. Symlinked into the volume, subdirectories created at startup
(a bind mount hides the tree baked into the image), and adopted only when
BACKUP_DIR is set so it never gates boot for compose deployments that do not
mount it.
- logger.js honours LOG_DIR. It hard-coded <backend>/logs, so logs could not
leave the container. Unset keeps the old path for every existing install.
- wait-for-db.sh derives its writable roots from STORAGE_PATH / DATA_DIR /
LOG_DIR instead of hard-coded /app paths, and mkdir -p's them before chown —
a bind-mounted /data hides the image's tree, and chown against a missing path
reports "the filesystem rejects chown", which is both wrong and a dead end.
- .dockerignore excludes backend/-prefixed runtime data. Docker reads only the
root file, so the unprefixed data/*.db, logs/* and storage/* rules missed
backend/data, backend/logs and backend/storage entirely; a checkout used to
run PicPeak would bake its database, photos, logs and SETUP_TOKEN into a
published layer.
- HEALTHCHECK follows $PORT rather than a hard-coded 3000.
- --max-http-header-size=32768 matches nginx's large_client_header_buffers
4 32k; Node's 16 KiB default would reject a guest carrying several
per-gallery JWT cookies.
docs/single-container.md is added as the in-repo reference the README links to.
The smoke job gains four assertions for the above: the one-volume layout and
writable backup destinations, the sqlite3 CLI, logs landing on the volume, and
the image carrying no runtime data from the build context.
Verified on a built image — named volume, bind mount and PORT=8080 all healthy;
every existing smoke assertion still passes, including / -> 302 /admin/login,
the rendered BRAND_TITLE, immutable assets, gzip and /s/<unknown> -> 404.
Co-authored-by: Luca-Timo <102960244+Luca-Timo@users.noreply.github.com>
* fix(docker): restore the SPA-fallback exclusions and close the build-context leak (#1042)
Both found by external review of the consolidated branch.
- The SPA catch-all had no backend-owned exclusions. This was a regression I
introduced while merging: #1067 carried a BACKEND_OWNED prefix list, and
taking this branch's server.js wholesale (correctly — its index:false and
in-process brand render are the better design) dropped it. /photos,
/thumbnails, /uploads and /fonts are static mounts whose middleware calls
next() on a miss, so the catch-all was answering 200 text/html under image
and font URLs instead of 404. nginx gave each of those its own location
block, so try_files never applied to them.
- backend/data is now excluded wholesale rather than by suffix. The suffix list
(*.db, *.db-wal, *.db-shm, SETUP_TOKEN) let real secrets through: a used
checkout carries ADMIN_CREDENTIALS.txt next to the database, plus -journal
files and any DATABASE_PATH not ending in .db. Since Dockerfile.aio builds
from the repository root and COPYs backend/ wholesale, any of those would be
baked into a published layer. The directory holds only runtime state and is
already gitignored in full.
smoke-aio gains an assertion that the backend static routes still 404, so the
exclusion cannot be dropped again silently.
Verified on a built image: /photos, /thumbnails, /fonts and /uploads misses all
404; /setup, /impressum, /gallery/x, /admin/login still 200; / still 302s to
/admin/login; /api/nope still answers JSON; /s/<unknown> still 404s; and the
image carries no *.db, ADMIN_CREDENTIALS.txt, logs or storage from the context.
* fix(aio): three failures that only surface outside a dev laptop (#1042)
Backups aborted on SQLite. getTableChecksums() built its digest with
`CAST(t.* AS TEXT)`, which is Postgres row-to-text syntax; SQLite parses
`*` there as a syntax error, so every backup threw before reaching the
.backup call. Since the all-in-one image ships SQLite by default, that is
every AIO install. Enumerate the columns via columnInfo() and sum their
lengths instead.
The shared /data mount root was never adopted. wait-for-db.sh chowned the
children it creates but not the mount point itself, so a host directory
arriving as 0700 with a foreign owner stayed untraversable by UID 1001
after the su-exec drop. Docker Desktop's permissive bind mounts hide this
completely, which is why local testing passed; a NAS share does not.
DATA_ROOT is now adopted first.
Maintenance mode locked the admin out of the box. The middleware runs at
server.js:493, long before the static block at 891, and exempted the auth
endpoints but not the page that calls them. With the backend serving the
frontend, /admin/login and /assets/* returned 503 JSON, so an admin who
enabled maintenance mode could never load the UI to turn it off. nginx
serves those paths in the compose stack, which is why it never surfaced
there. Guest and API surfaces stay gated.
Verified on a built image: checksums compute across all 95 tables; a bind
mount created 0700/4000:4000 boots healthy and ends up 1001:1001; with
general_maintenance_mode=true, /admin/login, /admin and /assets/* return
200 while /gallery/* and /api/gallery/* return 503 — and 503 across all
three once the exemption is removed again.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(aio): stop leaking .env into the image, fix the broken checksum test (#1042)
The Jest suite was red: mocking db.raw is no longer enough now that the
SQLite checksum branch asks the query builder for its column list, so
db(table) came back undefined and getTableChecksums failed on every PR.
The production code is right; the fixture needed to know about the call.
backend/.env was landing in the published layer. The root ignore file's
`.env`, `.env.*` and `data/*.db` rules read as unanchored but Docker
matches them from the context root, so they catch ./.env and never
backend/.env — and `COPY backend/ .` then puts a real JWT_SECRET at
/app/.env. Matched at any depth instead, the way **/node_modules in the
same file already is. Confirmed by building from a checkout carrying a
planted secret: before, `cat /app/.env` printed it back.
Business documents wrote outside the volume. quoteService, invoice
sending/reminders and contract signatures build paths from
process.cwd()/storage and never read STORAGE_PATH; compose hides it by
setting STORAGE_PATH=/app/storage with WORKDIR /app so the two are the
same directory. Here they are not, and /app is root-owned, so a quote or
invoice PDF failed to write as UID 1001 — and would not survive the
container if it had. Symlinked /app/storage into the volume, matching
the /backup symlink beside it. Teaching those services STORAGE_PATH is
the real fix and wants its own change.
Two smaller ones: the mount root is now chowned shallow rather than
recursively, since every child below it is already walked recursively
and a NAS-sized photo library should not be traversed twice on each
restart; and /assets/ joins the backend-owned prefixes, so a stale
hashed chunk requested by a tab left open across an upgrade gets a 404
instead of index.html served with 200 under a .js URL.
Verified on a built image: planted backend/.env and backend/probe.db are
absent; /app/storage resolves to /data/storage and a business-doc write
as UID 1001 appears on the host; a 0700 bind mount owned by 4000:4000
boots healthy; a missing /assets chunk 404s while the real bundle still
serves 200 as application/javascript. The databaseBackup suite is green
again, and the branch adds no failing suite that origin/main does not
already fail on the same machine.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* test(aio): teach the leak assertion about the storage symlink (#1042)
The previous check listed /app/storage/events and treated a hit as a
leak. That was true while /app/storage was either absent or a copied
directory; now it is a symlink into the volume, so the check followed it
and found the empty tree the image itself creates — a false positive on
its own design.
Check the shape instead: /app/storage must be a symlink pointing at
/data/storage, and the volume's photo tree must contain no files on a
fresh install. A real directory there now fails loudly, which is the
condition the assertion was always trying to catch. Also extended the
path list to /app/.env and loose database files, matching the
.dockerignore rules added alongside.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(aio): show the maintenance screen instead of raw JSON to guests (#1042)
The previous commit exempted the admin shell so an admin could still
reach the switch they had just flipped. Guests had the same problem for
the same reason: with no nginx in front, /gallery/<slug> reaches this
middleware long before the static block, so a visitor during maintenance
got a 503 JSON body where every other deployment shows the branded
maintenance screen the frontend already ships.
Replaced the two path-specific exemptions with the rule they were both
special cases of: a GET that is not an API call and not a backend-owned
content mount is the SPA shell, and the shell is inert HTML — it boots,
reads /api/public/settings (already exempt) and renders MaintenanceMode
on its own. Everything that carries real data stays gated: /api/*,
/photos/, /thumbnails/, /fonts/, and any non-GET.
Compose is untouched by construction, since nginx answers those paths
and they never arrive here.
Verified on a built image with the flag on: /gallery/x, /customer/x,
/admin and /admin/login return 200 text/html while /api/gallery/x/verify,
/photos/x.jpg and /thumbnails/x.jpg return 503 and a POST to a public API
still returns 503; with the flag off the same paths go back to 404. Added
a middleware test over that exemption matrix — over-exemption is the real
risk in this change, so it asserts the gated half too. It fails on five
cases without the fix.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(aio): stop the shell exemption from un-gating /og and the public CMS (#1042)
The previous commit exempted "any GET that is not an API call". That
negative rule reads as safe and is not: /og/gallery/<slug> and its
/cover render the event name and the hero thumbnail, /s/<code> renders
short-link previews, and `/` is handed to the public CMS. All four are
proxy_passed to the backend by nginx, so they were gated before this PR
in every deployment — the rule un-gated them, and for compose too, not
just the new image. A site switched to maintenance would have kept
publishing gallery metadata.
Replaced the guess with the split nginx already defines: exempt what the
frontend container answers itself, gate what it proxies. That is the
same rule the all-in-one image needs by definition, since its whole job
is to be both halves of that stack, and it now matches compose in both
directions rather than only in the direction the last commit tested.
Verified on a built image with the flag on: /admin/login,
/gallery/<slug> and /customer/* return 200, while /, /og/gallery/x,
/og/gallery/x/cover, /s/abc, /robots.txt, /api/* and /photos/* return
503; with the flag off all of them behave normally again. The middleware
test grew the gated cases — it now covers 21, most of them asserting
what must NOT be exempt.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(aio): give the image a FRONTEND_URL default so share links are absolute (#1042)
getFrontendBaseUrl() reads FRONTEND_URL, falls back to the
general_site_url setting, and otherwise returns an empty string — which
makes share_url come back as a bare "/gallery/<slug>/<token>". Compose
defaults the variable to http://localhost:3000, but the documented
one-liner for this image passes only JWT_SECRET, so every fresh
single-container install handed out relative links in API responses, QR
codes and emails.
Defaulted to the same value compose uses; -e FRONTEND_URL=https://...
overrides it, as does the site URL field in Settings.
Found by pointing tests/e2e/local at a running AIO container:
auth/06-api-tokens asserts share_url matches /^https?:\/\//, and it was
the one spec that failed for a product reason rather than a harness one.
It passes now, and the suite is 19/20 against the image — the remaining
failure is smoke/02-auth-flow, whose seed helper shells out to a
hard-coded `docker exec picpeak-backend`, so it cannot arrange its
precondition against any other container.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* feat(aio): mark the image so face recognition stays off (#1042, #1074)
Face recognition needs a separate ML container this image does not contain,
and enabling it here would add a second image-processing pipeline competing
with Sharp for the CPU and memory of a container sized for one photographer
plus guests browsing. The failure mode would not be a clear error — just a
slow install that looks broken.
The backend gate for this lands in #1075 and keys on PICPEAK_SINGLE_CONTAINER.
Without this line the guard never triggers on an actual all-in-one build, so
the two changes have to arrive together: whichever merges second completes
the pair. Verified against this file's exact value — isFeatureEnabled()
returns false with it set.
An explicit marker rather than inferring from SERVE_FRONTEND or the SQLite
path, because legitimate multi-container deployments do both of those and
should keep the feature.
Also adds it to the Limits section of docs/single-container.md, next to the
SQLite and Redis constraints, since that is where someone will look before
choosing this image.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: the-luap <paul-nothaft@hotmail.de>
* fix(storage): write business documents under STORAGE_PATH, not the cwd
persistDocPdf, the invoice sending and reminder writers and both contract
signature writers built their target from
`path.join(process.cwd(), 'storage', 'business-docs', ...)` and never
consulted STORAGE_PATH. docker-compose.yml and
docker-compose.production.yml both pin STORAGE_PATH=/app/storage and the
image's WORKDIR is /app, so on a stock deployment the two expressions
name the same directory and nothing looked wrong.
Point STORAGE_PATH anywhere else and quotes, invoices, Mahnungen and
contract PDFs land outside the configured storage root: missed by the
backup walker, invisible to the storage accounting, and gone when the
container is replaced. It also fails outright where the working
directory is not writable by the runtime user.
Routed all six writers through getStoragePath(), the resolver the rest
of the app already uses. Two read-side sites of the same class came
along: the custom PDF font lookup now checks the storage root before the
legacy cwd path (a font under STORAGE_PATH/fonts was simply never found,
and the document silently fell back to the built-in face), and the
dev-test scratch directory follows the same root.
Left alone deliberately: resolveLogoFile and adminBusinessProfile
already try both roots, so their cwd reference is a legacy fallback
rather than a miss.
No migration needed — the persisted path is stored absolute, so rows
written before this keep resolving to where those files actually are.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(storage): allow the configured contract root, and move signature images too
Two holes in the previous commit, both found by review.
Contract downloads would have broken. assertContractPdfPath() guards the
admin unsigned/signed PDF routes and GET /api/public/contracts/:token/pdf,
and it listed only <cwd>/storage/business-docs/contract. Moving the
writers to STORAGE_PATH without moving that root meant every newly
generated contract was refused with PATH_OUTSIDE_STORAGE — a worse
failure than the bug being fixed, and only on the installs the fix was
for. The configured root is now allowed alongside the cwd one, which
stays for contracts written before the move; their absolute paths are in
the database and still resolve. Note the sibling root on the next line
already honoured STORAGE_PATH, so the helper was half-migrated already.
persistSignatureImage() still wrote customer and admin signature PNGs
under process.cwd(). It was missed because its path.join is spread over
seven lines while the others are single-line — and the regression test
compared against the single-line literal, so it reported green over a
live bug. The test now collapses whitespace before matching, which is
the only reason a formatting difference ever hid this. A sweep of the
whole of src/ with the same normalisation confirms the remaining
process.cwd()/storage references are all deliberate
`STORAGE_PATH || cwd` fallbacks, not misses.
Added a case that drives assertContractPdfPath against real files on
disk — the guard realpaths both the file and its roots, so a test using
imaginary paths proves nothing. It fails without the fix.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(storage): resolve the contract guard's root through the shared resolver
The guard still built its own `STORAGE_PATH || <cwd>/storage`. That
matches getStoragePath() only while STORAGE_PATH is set — with it unset
the shared resolver falls back module-relative to <repo>/storage while
this fell back to <cwd>/storage, and the backend is normally started
from backend/, so the two name different directories. Writers and guard
then disagreed about where contracts live and the download routes
refused them, which is the same failure the previous commit fixed for
the configured case, reappearing in the fallback case.
One resolver on both sides now, which is the point of the whole change.
Docblock updated to describe the three roots as they actually are.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
* fix(storage): make the fallback test safe, and align the backup diagnostics
The test added in the previous commit was dangerous. To exercise the
STORAGE_PATH-unset case it deleted process.env.STORAGE_PATH and then, in
cleanup, recursively removed `<resolved root>/business-docs` — which
with the variable unset resolves to the developer's real, gitignored
<repo>/storage. Running `npm test` in a working checkout would have
destroyed local business documents. This checkout has 65 MB there,
including a populated business-docs tree.
Rewritten to mock the shared resolver instead. That is both safe (every
path stays in the tmpdir) and a sharper assertion: if the guard consumes
getStoragePath() the mock moves its root, and if it went back to rolling
its own expression the mock would have no effect and the test fails —
which is exactly the regression being pinned.
backupCoverageService and backupIntegrityService kept their own
`STORAGE_PATH || cwd` roots. The backup walker itself already falls back
module-relative, so with the variable unset the two diagnostics
inspected a directory neither the walker nor the writers use and would
report the business-docs tree as missing while it was in fact being
backed up. Both now use the shared resolver.
No regression: the same jest invocation over contract/quote/invoice/pdf/
backup suites gives an identical 11 failed, 24 passed before and after —
those failures are a locally missing cron-parser dependency and
reproduce on an unmodified tree.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Three specs acquire an admin token with `const body = await res.json();
return body.token`. The admin login has not returned a token in its body
for some time — establishAdminSession() sets the JWT as the httpOnly
`admin_token` cookie and responds with `res.json({ user })` — so the
token was undefined and every one of them failed at the first assertion,
before exercising anything they were written to cover.
Server-side the cookie and an Authorization: Bearer header are
interchangeable (see middleware/gallery.js, which reads the cookie first
and accepts an admin-typed Bearer second), so the fix is to read the
value back out of the context cookie jar and keep threading it as a
Bearer. Every downstream call in these specs stays exactly as it was.
Measured against a real stack, running only these three files:
before 0 passed, 6 failed — all six at the token assertion
after 3 passed, 3 failed
The three that still fail no longer fail on auth: they get deep into the
flow and then miss UI that has since changed (a settings label, a
locator that no longer resolves). That is a separate and much larger
staleness problem across this directory — a full run is 12 passed
against roughly two dozen failures of that kind — and it is not
addressed here.
Worth knowing: no CI workflow runs tests/e2e at all, which is why this
rotted silently while `npm run test:e2e` stayed documented in CLAUDE.md.
Wiring it up is the obvious follow-up, but it has to wait until the
suite is actually green, or it would just pin main red.
Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(gallery): make per-event banner overrides actually work, both banners (#440, #932)
The promo banner shipped with a per-event inherit/custom/off override that
never reached a guest. GalleryView reads promo_mode from the /photos payload,
and /photos never sent it — so every gallery resolved to 'inherit'. Setting a
gallery's promo banner to "Off" did nothing; the global banner kept rendering.
The info banner (#932) mirrored that shape and inherited the same gaps.
Four places dropped the fields; all four now carry both banners:
1. GET /gallery/:slug/photos — send promo_mode/promo_markdown alongside the
info fields. This is the fix that makes "Off" mean off.
2. POST /admin/events — the validators accepted both banners and the insert
discarded them, so an API client could POST info_mode:'off', get 201, and
find the row on 'inherit'. Markdown is stored only for 'custom', matching
the PUT rule.
3. POST /admin/events/:id/duplicate — copy both from the source row. The
dialog promises the copy "inherits the branding, behaviour, feedback, and
category configuration"; a muted gallery un-muting on duplication is the
opposite of that.
4. PUT /admin/events/:id — resolve the effective mode from the STORED row when
a partial update sends only the markdown. Previously updates.promo_mode was
undefined on such a request and the text was parked on an inherit/off
gallery, then resurfaced when someone later switched it to 'custom'. The
lookup is lazy: one extra query, only on that path.
The two normalisation blocks are now one loop over both banners, so the pair
can't drift apart again.
Verified in a browser, both directions against the same global banner:
promo_mode='off' -> not rendered; 'inherit' -> rendered. The /photos payload
went from promo_mode ABSENT to carrying the value.
* fix(gallery): thread promo into the reveal view, drop stale markdown on duplicate
External review, round 1 on this PR. Two gaps in the plumbing it introduced:
- The reveal-hidden branch copied only the info fields from /photos. Now that
/photos carries promo too, a reveal-hidden gallery with promo_mode 'off'
still fell back to 'inherit' and showed the global banner on the first load
after login. Thread both banners there.
- The duplicate copied markdown verbatim. A row written before the PUT
normalisation landed can hold text while its mode is 'inherit'/'off', so the
copy inherited hidden text that would resurface the moment someone switched
it to 'custom' — violating the very invariant this PR establishes. Copy
markdown only when the source mode is 'custom'.
Test covers the stale-markdown source explicitly.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): info banner above the photo grid (#932)
A short informational note rendered at the TOP of a gallery, above the
photos. Distinct from the promotional banner (#440), which stays by the
footer for marketing copy — the reporter's case is an onboarding hint ("use
the menu button to filter"), which is useless below a gallery the guest has
to scroll past first.
Mirrors the promo feature's shape rather than inventing a second one: a
global default in Settings → Branding (branding_info_markdown) plus a
per-event inherit/custom/off override. Markdown via the existing
MarkdownContent sanitiser — no raw HTML, no CSS injection. Empty global
default means nothing renders, so upgrading changes nothing visible.
Deliberately NOT included: an alignment knob (this is short helper copy, not
marketing layout) and guest dismissal — the issue lists dismissal as a
nice-to-have, and it needs per-guest persistence that is its own decision.
Migration 176 is idempotent (hasColumn / existing-key guarded).
Note on the payload plumbing: the per-event fields travel in the /photos
response, not just /info. GalleryAuthContext seeds its cached event from the
gallery LOGIN response — a small identity subset — so anything absent there
is undefined right after a guest signs in. /photos is the payload that
refreshes on every gallery load, which is why the fields were added there
and why GalleryView reads them from `data.event`. Verified in a browser
across all three modes; reading them from the context event instead silently
collapsed every override back to 'inherit'.
* fix(branding): map branding_info_markdown on read so saving can't wipe it (#932)
External review caught this. BrandingSettings declared no info_markdown and
formatBrandingSettings never mapped branding_info_markdown, so BrandingPage's
hydration — setBrandingSettings(prev => ({ ...prev, ...formatted })) — kept
the empty-string initializer instead of the persisted value. The form loaded
blank and the next Save posted '' back, wiping a configured banner. Silently:
the gallery keeps rendering the old copy until that save lands.
This is the same bug the footer/promo fields hit in #441 + #440 / #460, which
the read mapper still carries a comment about. Add the field to the interface
and the mapper, and pin the round-trip for the whole editable branding set so
the next field added is caught by a test rather than by a user losing copy.
Verified: the new test fails 3/4 with the mapper line removed.
* fix(gallery): honour the info-banner override in the reveal-hidden view (#932)
External review, round 2. The hidden-until-reveal branch renders GalleryLayout
with the context `event`, which is seeded from the gallery login response and
carries no banner fields — so while a gallery was hidden, a per-event 'off'
silently resolved to 'inherit' and the global banner appeared on a gallery the
admin had muted.
Resolve the fields there the same way the main render path does. The two
full-page layouts (gallery-premium, gallery-story) are deliberately left alone:
they return before GalleryLayout and render no header, footer or promo banner
either — injecting a wrapper into layouts documented as having 'their own
integrated UI' would be a design change, not a fix.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(pdf): RFC 6266-encode Content-Disposition on quote/invoice PDFs (#1024)
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.
Note this corrects the issue's diagnosis: it reported umlauts as the 500
case, but umlauts are inside latin1 and mangle rather than throw. Both
symptoms share this root cause and both are fixed here.
Route through buildContentDisposition() (utils/filenameSanitizer, already
used by secureImages.js), which emits an ASCII fallback plus the RFC 5987
`filename*=UTF-8''…` form, so the unicode name survives in browsers and the
header stays legal. Applied to all six sites: adminQuotes (persisted +
preview), adminInvoices (persisted + preview), customer (quote + invoice).
Also correct buildPdfFilename's docstring, which advertised the preserved
non-ASCII as suitable for Content-Disposition — the exact misreading that
produced these call sites.
* test(pdf): pin the ASCII fallback for fully non-Latin customer names (#1024)
A name written entirely in another script leaves the legacy filename= token
with just the document number (Q-2026-0042_.pdf) — filename* carries the real
name. That's the intended trade, but it's the token a client without RFC 5987
support actually saves, so assert it stays legal, non-empty and carries the
document number rather than leaving it unpinned.
* fix(pdf): don't split surrogate pairs when truncating the filename (#1024)
Codex review caught this. sanitiseSegment caps each segment at 80 UTF-16 code
units, so a cap landing inside an astral character (emoji, rarer CJK) left a
dangling high surrogate. encodeURIComponent throws URIError: URI malformed on
a lone surrogate, so buildContentDisposition — the helper this PR routes the
six PDF endpoints through — 500'd for e.g. company_name = 'a'.repeat(79)+'🎉',
well inside the 120-char validator limit. Same 500 the PR set out to remove,
reached a different way.
Drop the orphaned surrogate instead of widening the cap, so the byte budget
the limit exists to protect is unchanged. Tests cover both boundary cases and
assert the cap semantics; they fail against the previous slice().
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Reshaped onto main after #1039 landed the coercion engine
(typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now
only the policy delta on top of it:
- validateManifest: replace the CLI-only allowEngineSwitch opt-in with a
direction rule — sqlite → pg allowed (upload UI and CLI alike),
pg → sqlite refused with a message naming the supported direction
- importFromPicpeak: derive crossEngine from the manifest's engine
(absent field = target engine, the exact pre-change behavior), log it,
return it; route passes it through
- scripts/migrate-sqlite-to-postgres.js: rely on the shared gate, drop
the flag
- restore card: direction stated in the intro, cross-engine notice after
a converting restore; both strings in en.json + de.json; removed the
orphaned settings.backup.picpeak locale node (unreferenced, stale copy)
- picpeakCrossEngine.test.js: direction policy, epochToIso (ms, seconds,
numeric strings), coerceForTargetEngine units, plus
PICPEAK_PG_TEST_URL-gated real-Postgres stored-value assertions
Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
The .picpeak restore suites gate their Postgres cases behind
PICPEAK_PG_TEST_URL and describe.skip themselves out when it is unset. That
variable was set in no workflow, so those cases have never run in CI — the
suites reported green while silently skipping the half that needs a real
database: sequence resync, operator/role preservation across a cross-instance
restore, and whether a coerced row lands with the right STORED VALUES rather
than merely not throwing.
Add a postgres:15-alpine service to the backend job (same shape schema-drift
already uses) and point the variable at it. Everything else in the suite still
runs on SQLite; this only un-gates the cases that were skipping.
Verified against a real Postgres 15 before wiring: picpeakRestorePg 4/4 and
picpeakCrossEngine 11/11 (8 of which were previously skipped across both).
Matters now because #1043 opens sqlite -> pg restore to the upload UI, so the
coercion layer's correctness stops being a CLI-only concern.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(storage): add S3 client timeouts so a dropped connection can't wedge uploads
* fix(storage): use socketTimeout, not requestTimeout, for the dead-connection guard
* fix(storage): make S3 timeouts generous — short connectionTimeout breaks pooled reads
---------
Co-authored-by: Peifu Mo <peipeimo@Peifus-MacBook-Pro.local>
* feat(permissions): granular permission gating + role editor & presets
Make every admin feature permission-gateable so multi-user studios can
split capability across roles (#747, and phase 1 of #743).
- Split the catch-all settings.edit into dedicated dangerous-config perms
(banking / domains / security / integrations / features): a team member
can no longer change IBAN, domains, SSO, webhooks, API tokens or feature
flags. Reads keep an OR with settings.view so existing roles keep
visibility. The site-URL write inside /general is change-gated on
settings.domains.
- Add dedicated perms for admin surfaces miscategorised under settings.*
(whatsapp, event_types, image_security, notifications, system) plus
roles.manage and vat_codes.view; gate the previously-ungated VAT read.
- Boot self-heal (_permissionsBoot.js): super_admin always holds every
permission (tracks-all) so new perms never need a compensation
migration; all other roles stay frozen (no silent escalation on upgrade).
- Seed two presets: Solo Photographer (full operator) and Team
Photographer (contributor — view events + manage photos + read-only CRM;
no settings/users/billing edits, no events.edit).
- Role editor: adminRoles CRUD (create/edit/clone/delete + permission
matrix; system roles protected, super_admin immutable) and a Roles tab
with a category-grouped matrix and preset cloning.
- Settings page tabs are permission-gated with snap-back; i18n en/de.
Migration 174. Backward-compatible: admin/editor/viewer unchanged.
* feat(permissions): hide in-page action buttons a role can't use
Wrap mutating controls on the surfaces restricted roles actually reach
(Events list, Archives, gallery photo grid, event detail) in
PermissionGate so they are HIDDEN when the user lacks the permission,
rather than shown-then-403:
- Events list: create / bulk archive / bulk delete / row archive /
row delete / download-archive.
- Archives: restore / download / delete.
- Photo grid: single + bulk delete (photos.delete), per-photo download
(photos.download), bulk move/hide/show (photos.edit).
- Event detail: edit / rename / publish (events.edit), duplicate
(events.create), archive (events.archive), create-invoice
(bills.manage); the Actions card is hidden entirely for view-only roles.
- Photos tab: upload / external import (photos.upload), export menu
(photos.download).
Backend already enforces these with 403; this is the matching UX so a
Team Photographer never sees delete/settings controls.
* fix(permissions): close settings-split bypass via generic settings writers
Security review found the settings.edit split was bypassable: the generic
settings writers (/general, /analytics, /seo, /security) upsert arbitrary
setting_keys, so a role holding only settings.edit (or settings.security)
could write keys owned by a narrower permission — repointing the public
site URL (settings.domains), security policy (settings.security) or
VAT/accounting config (settings.banking) via the wrong endpoint.
Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop
any protected key the caller isn't permitted to write (general_site_url →
settings.domains, security_* → settings.security, accounting_* →
settings.banking). Dedicated routes still work because their caller holds
the matching perm. Replaces the narrower in-handler site-URL guard.
Also fix two tests affected by the RBAC changes:
- authzPermissionGaps: API-token management moved to settings.integrations,
so grant that (not settings.edit) to exercise the ownership 404.
- AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated
and the test renders without a PermissionsProvider).
* fix(permissions): address upstream review (#1045)
- Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates
from #1035; the collision made picpeakImportService's forward-only restore
guard treat both as order 174 and accept a newer .picpeak onto an older schema).
- Contain the roles.manage blast radius (delegation, not root escalation): a
non-super_admin can no longer edit their own role, nor grant any permission
their own role doesn't already hold (createRole + updateRole).
- Protected-key denial now 403s (naming the keys + required perms) instead of
silently stripping and reporting "saved" (adminSettings generic writers).
- Reserve team_photographer so a custom role can't squat the preset name.
- Boot self-heal: per-step try/catch so a role_permissions insert race on one
replica doesn't skip preset seeding.
- Forward-project the feature .manage perms that also replaced settings.edit
gates (whatsapp/event_types/image_security/notifications/system), matching the
settings.* split projection so the pattern is symmetric for phase-2.
- Guard exports.down's roles/admin_users queries with hasTable.
* fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045)
Round-2 review:
- The protected-key 403 fired on key PRESENCE. The General tab re-posts
general_site_url on every save, so a settings.edit-only role (the office
manager this PR enables) got 403'd on every General save even when the URL
was unchanged. Restore change-detection: compare the incoming value against
the stored one and 403 only on an actual change; unchanged protected keys are
dropped so the rest of the save proceeds. Only /general is affected.
- Commit the self-amplification guard test (was run locally, never staged):
adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit
its own role, can't escalate another role; super_admin bypasses;
team_photographer name reserved.
- Add adminSettingsProtectedKeys.test.js pinning the change-detection: an
unchanged general_site_url saves, an actual change 403s, super_admin changes it.
* 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 (nothing did before, which is
why this went unnoticed for so long)
- 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.
The engine-selection tests resolve knexfile in a child process with a clean
cwd — dotenv.config() would otherwise let a developer's backend/.env decide
the answer instead of the knexfile defaults under test. Fake credentials in
the describeEngine tests are built at runtime rather than written inline, so
secret scanners don't flag a literal after `password:`.
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 from the previous commit 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.
Nothing moves until the operator decides.
- once Postgres holds the data, the next restart switches over on its own.
- an explicit DATABASE_CLIENT is always honoured.
The check is 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 and strand the operator on an empty install.
wait-for-db.sh resolves the engine and exports DATABASE_CLIENT before the
migration step, so the runner and the server always agree. Manual migration
runs (no entrypoint, no exported client) now refuse rather than build a schema
in the wrong database.
Adds scripts/migrate-sqlite-to-postgres.js for moving the data across. It
reuses the .picpeak export/import services rather than hand-rolling a
cross-engine copy — they already handle FK suspension, JSON columns and
Postgres sequence resync. Two things had to be added 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. Postgres rejects both outright
("date/time field value out of range: 1786548038763"). Coercion is driven
by the TARGET schema, never guessed from the value.
Verified end to end against a real PostgreSQL 15: a seeded SQLite install
migrated across with booleans, timestamps and foreign keys intact, and the
serial sequences correctly advanced (the next INSERT got id 2, not a
primary-key collision). Photo files on disk are never touched and the SQLite
file is left in place as a rollback.
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>
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>
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>
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>
Phase 3 (final) of #1000. The deep content now lives on the docs site (PicPeak/docs#7), making docs.picpeak.app the single source of truth and removing the in-repo copies.
README links flip to docs.picpeak.app; the roadmap table is retired in favour of GitHub Issues. Deletes docs/_to-migrate/ and the five migrated pages. docs/migration-to-org.md stays — it's repo-transitional, not docs-site content.
In-app references to the deleted files are repointed at the docs site, including the CRM disclaimer strings in en.json/de.json and the contract-editor fallback.
Closes#1000.
Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default.
STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries.
RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it.
Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input.
Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats.
Closes#858.
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, so portrait photos lost their top and bottom and 'Black Bars (No crop)' looked inert.
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.
Fixes#1015.
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), transitive via postcss
- js-yaml 4.3.0 -> 4.3.1 (GHSA-5p4m-2wfm-xmqj, quadratic CPU in !!omap resolution), direct dependency
Lockfile-only; the existing ^ ranges already permitted both fixes. Clears the two open Trivy code-scanning alerts on main.
With OIDC enabled the login page also renders a 'Sign in with <provider>' button whose accessible name matches the unanchored /Sign In/ locator, so Playwright strict mode failed every test that logs in — 7 of 13 in the local smoke suite, which is also the pre-push gate. CI never hit it because its databases seed without OIDC config.
Anchors the regex to the full accessible name in all six call sites.
Phase 3 validated a stored ID token hint against the currently configured issuer, but the oversize path never got that check: an ID token above the 3.9KB cookie limit was stored as the bare string 'sso', which collapsed to an undefined hint at logout and skipped validation entirely. Changing the issuer while such a session was live bounced the user to the new IdP on logout.
Stores sso.<base64url(issuer)> instead and moves all marker interpretation into buildEndSessionUrl: raw ID token -> iss/aud-validated hint, issuer-tagged marker -> round-trip without a hint, anything else -> no round-trip. Every branch fails closed.
Refs #798.
Closes#1003.
#999 centralised the attribution so branding_hide_powered_by is honoured
everywhere, but GalleryLayout kept its own inline guard. The gallery footer
therefore still flashed — it kept `!brandingSettings?.hide_powered_by`, where
undefined is falsy, so a white-labelled instance briefly showed the attribution
on first paint, on the surface a white-label customer is most likely to see.
And there were two implementations of one rule, which is the bug class #999
existed to close.
The footer appends the attribution to its copyright line inside an existing
<p>, so a straight swap would nest a <p> in a <p>. Added an inline variant
rendering a <span> that carries the leading ' | ' itself: the separator belongs
to the component, since a caller placing its own would have to repeat the
visibility guard to avoid leaving a dangling separator when the attribution is
hidden.
No extra request — GalleryView already uses usePublicSettings(), the same hook
and react-query key, so the cache is shared. The footer also picks up
common.poweredBy, so it is translated rather than hardcoded English.
Removes the now-unread hide_powered_by from GalleryLayout's prop type and the
mapping feeding it in GalleryView.
Four cases cover the variant — span not paragraph, separator present, separator
hidden with the attribution when white-labelled, hidden while loading. Each was
checked against the pre-fix shape: rendering a <p> or moving the separator out
breaks one.
Phase 1 of the README slim / docs-migration plan in #1000.
README goes from 577 to ~191 lines: hero, one Quick Start, a Documentation
index, comparison table, tech stack and a table of contents. The deep inline
prose moves into a temporary docs/_to-migrate/ staging folder (webhooks,
storage backends, first-run setup, system requirements, roadmap) so README
links keep resolving until the docs-site pages are live.
Existing docs/*.md referenced by app code are deliberately left in place —
crm-disclaimers.md (frontend TSX, i18n, a backend route and migration),
fonts.md (server.js), accounting-inbound-invoices.md (Dockerfile) and
migration-to-org.md (UpdateNotification.tsx, MigrationBanner.tsx). Moving them
is a separate, code-touching change.
Verified before merge: merges cleanly against main with no conflicts; all 14
in-repo links resolve in the merged tree; no docs file is deleted or renamed;
and the registry-move notice from #995 survives the rewrite in condensed form,
keeping 'still responds but its tags are frozen at 2026-05-27' plus the
migration-to-org.md link. The fuller symptom explanation remains in that doc,
which the README links to.
Follow-up per #1000: port docs/_to-migrate/* into docs.picpeak.app, then flip
the README links and delete the staging folder.
Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
Closes#1005.
The two ownership guards added during the #998 review were correct on merge but
untested. They are the only thing between a scoped admin and every other
admin's ORIGINAL files, since a transfer serves those over an unauthenticated
token URL.
14 cases: filterOwnedPhotoIds (own / foreign / ownerless-legacy / mixed /
non-existent / super_admin), addFiles gating on the same rule, listTransfers
scoping plus the absence of token/upload_token/download_url/upload_url from the
list payload, and getTransferOwner.
Each was checked against the pre-fix behaviour rather than only passing against
current code — reverting each guard in turn fails exactly the cases covering it:
ownership filter 3, list scoping 1, payload strip 1, guard registered late 1.
requireTransferOwnership is module-local, so its two contracts are asserted at
the source following the #596 pattern: that router.use('/:id', ...) precedes
every /:id route — ordering is the whole mechanism, and a late registration
would guard nothing while still looking present — and that missing and foreign
ids both answer 404, so the endpoint is not an existence oracle.
Tests only; no production code touched.
Closes#997.
Send original files from any event as a token-protected download link, with an
optional client-upload channel. Strictly opt-in behind a new `transfers`
feature flag, default OFF.
Migrations 170-172 (transfers, transfer_files, transfer_extra_files,
transfer_uploads, transfer_recipients, transfer_downloads, default settings and
two email templates) — all hasTable/hasColumn-guarded and idempotent, with
destructive statements confined to down().
Backend: transferService (CRUD, 256-bit download token, 6-char upload token,
cross-event ZIP streaming of originals), admin CRUD routes, and two public
token routes. transferCleanupService runs an hourly retention sweep; source-event
photos are never touched. All three routers fail closed via
requireFeatureFlag('transfers').
Review closed two ownership blockers, both the same root cause — permissions
used where ownership was needed:
- photoIds arrived from the request body and were validated only for existence,
so a scoped admin could bundle any event's originals and hand them out through
the public download token. filterOwnedPhotoIds now resolves ids to their events
and gates them through filterOwnedEventIds, on both the create and add-files
paths.
- The transfer list was unscoped and carried each row's download token, so any
admin with events.view could read another's token and fetch their originals.
The list is now scoped by created_by, the token/url fields are stripped from
the list payload, and a single router.use('/:id', requireTransferOwnership)
covers all twelve /:id routes, 404ing foreign and missing alike.
The admin photo picker filters its event list to the same rule, so the UI stops
offering picks the API would discard.
Fork-PR workflows had not been approved since the fix commits, so the PR's green
checks were stale against the pre-fix head. Verified by dispatching tests.yml
against the actual head: backend and frontend both green.
Follow-up: neither ownership guard has a regression test yet.
Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
branding_hide_powered_by only hid the attribution on the main gallery footer. It
stayed visible on the gallery password screen, client access page, Premium
layout, admin and customer login, accept-invite and CMS pages — AdminLoginPage
rendered it unconditionally with no guard at all, so the setting genuinely did
not apply there.
Routes those surfaces through one <PoweredBy /> component in components/common
that reads the public setting itself (the DynamicFavicon pattern) and renders
nothing when white-labeling is on, including while the settings are still
loading so a white-labelled instance never flashes the attribution.
Also collapses three duplicate translation keys (gallery.poweredBy,
adminLogin.poweredBy, customer.login.poweredBy) into a single common.poweredBy,
and translates pages that had 'Powered by' hardcoded in English across all 8
locales.
Fork-PR workflows were never approved so CI did not run. Verified locally
against cf243b44: tsc --noEmit clean, ESLint clean, vitest 124 passed across 24
files, and npm run build succeeds.
GalleryLayout.tsx keeps its own inline guard and is not routed through the new
component; tracked separately.
Co-authored-by: lbossuyt <lbossuyt@users.noreply.github.com>
Closes#985.
README and migration-to-org.md both claimed the old path 'is no longer served'.
It is served — ghcr.io/the-luap/picpeak/backend:latest returns a complete image,
created 2026-05-27, label version: main. The registry responds normally; it just
never receives anything new.
That inaccuracy is what generates reports like #982. Told the path is not
served, an operator runs docker compose pull, watches it succeed, runs docker
rmi and pulls again, watches that succeed too, and concludes the problem lies
somewhere other than their image path. Nothing reports an error anywhere; the
only symptom is an update notice that never resolves.
Say what actually happens — the path freezes rather than failing — and add a
self-diagnosis via docker image inspect on both paths, with the 2026-05-27 date
and the 'main' version label as the tells. MigrationBanner's wording is left
alone: 'no longer being updated' was accurate.
This is the delivery mechanism for #985. There is no in-app channel:
MigrationBanner shipped a month after the freeze, the #993 update-check notice
cannot fire on installs running their own frozen backend, and the changelog
modal that renders release notes shipped two days after the freeze. What reaches
these operators is GitHub, and the GHCR page for the retired package — which
renders this README through the images' own org.opencontainers.image.source
label, so the fix propagates to the dead path's own page automatically.
Closes#868.
A logged-in admin opening a published, password-protected gallery is let
straight in, mirroring the existing draft-visibility bypass.
Mechanism: an explicit ?admin_preview=1 intent flag AND a verified admin session
read from the httpOnly admin_token cookie (or an admin-typed Bearer) — never a
token from the URL. This retires the old ?preview=<raw-admin-JWT> scheme, which
leaked a 24h admin token into the address bar, referrers and proxy logs.
Per-request bypass only: no gallery JWT is minted, the password endpoint is
never reached so the login_attempts lockout buckets stay clean, and admin
previews are excluded from guest analytics (access_logs, download counts,
per-photo view_count, notification bells).
Review (two rounds) closed three blockers and two concerns:
- Transport: verifyGalleryAccess now resolves admin preview before any gallery
credential, and isAdminPreview reads the admin cookie first and type-checks
every candidate — so an admin Bearer no longer 403s on the type gate, and a
coexisting gallery session can no longer shadow the admin cookie.
- Reveal mode (#838) is a second consumer of isAdminPreview; its bypass is
unchanged, only the transport moves. revealMode.test.js updated off the
retired scheme and now carries a coexisting gallery Bearer.
- Admin previews no longer inflate per-photo view counts, and the internal photo
redirects preserve the flag via withPreview() so they still authorise.
- Happy path: GalleryPage renders GalleryView directly for a preview instead of
attempting the public empty-password auto-login, which 401'd against a
genuinely protected gallery and stranded the page on the skeleton.
The backend job timed out once at the 10-minute CI limit; a re-run completed in
2m02s, in line with main's ~2m10s baseline, so that was a runner flake rather
than a hang.
Relates to #985 — does NOT close it.
Adds registryMigrationRequired to the update-check payload (stable channel below
3.45.0) and an amber block in UpdateNotification explaining that the retired
registry path still responds, so `docker compose pull` appears to succeed while
serving the same frozen build.
Known limitation, established in review and merged deliberately: this cannot
reach the operators #985 describes. PicPeak is self-hosted, so the update-check
code runs inside the operator's own image — a v3.44.0 install runs v3.44.0's
backend forever, and the only external call returns release metadata, not logic.
Every build containing this predicate is >= 3.45.0, where it is false by
definition. The release-notes fallback fails too: the changelog modal shipped
2026-05-29, two days after the freeze.
Correct for any future rename, no runtime cost, but #985 stays open — the
population it describes still has no in-app channel. Viable routes are external
(retired GHCR package description, repo README, docs).
'0.0.0' is excluded from the predicate: that is getCurrentVersion's fallback for
an unreadable package.json, i.e. a broken install, not a pre-rename one.
linkDealToProject re-points a deal's quotes, contracts and events into
`projectId`. Its lineage guard vets the SOURCE events and its comment assumed
the route had vetted the destination — true only for attachDocumentToProject.
quoteService.create/update and contract crud.create/update take `projectId`
straight from the request body behind quotes.manage / contracts.manage, which
are permissions, not ownership; adminQuotes.js and adminContracts.js carry no
ownership guard at all.
The lineage guard did not cover it: it is skipped when the deal has produced no
event yet, which is the state of a newly created quote, and an unassigned
destination ADOPTS the deal's customer rather than rejecting it.
A scoped admin could therefore write into another admin's project, and on an
OWNERLESS project (created_by IS NULL — legacy rows migration 167 could not
attribute) escalate to a read: once the quote converts to an event it becomes
the project's only linked event, which is the condition ownedProjectsSubquery's
second branch grants ownership on.
Vetted at the service choke point all four callers share, ahead of both the
null-deal early return (callers write project_id before calling, and deal_uuid
is nullable) and the customer check (whose 422 vs 404 was an enumeration
oracle). 404 PROJECT_NOT_FOUND throughout. super_admin unaffected.
Closes Trivy code-scanning alerts #414-#418 on the backend image.
brace-expansion 5.0.8 -> 5.0.9 CVE-2026-69152 (high) DoS via unbounded
intermediate arrays
ip-address 10.2.0 -> 10.4.0 CVE-2026-69192 (high), CVE-2026-54272 and
CVE-2026-69198 (medium) — SSRF and
trust-boundary bypasses. Needs 10.3.1+ to
clear all three.
postcss 8.5.18 -> 8.5.23 CVE-2026-69153 (medium) information
disclosure via crafted sourceMappingURL
ip-address and brace-expansion were already in overrides but pinned below the
new fixed versions; the floors just needed raising. postcss reaches the image
through sanitize-html — the direct pin is not an import, it forces the
transitive copy to dedupe to a known version, so it moves with the bump.
Only the backend image is affected: the frontend production stage is
nginx:1.30-alpine and ships no node_modules.
Each lockfile now holds exactly one entry per package, all at or above the
fixed version, and the image installs via npm ci --omit=dev so the lockfile is
authoritative.
Closes#983.
The two cross-add counter queries added in #979 were enabled on customers.edit,
but neither endpoint checks that permission:
HoursSection -> GET /expenses/inbound/by-customer/:id needs accounting.view
CustomerCrmPanels -> GET /customers/:id/hour-entries needs customers.view
An admin holding customers.edit but not the corresponding read permission fired
a guaranteed 403 on every customer-detail render. It degraded safely — the count
stayed at its 0 default so the cross-add was never offered, which is the right
outcome for that role — so this was request noise rather than broken behaviour.
Each guard now requires both: the read permission to fetch the count, and the
write permission because there is no point offering the cross-add to someone who
cannot create the combined invoice.
No seeded role is affected: migration 123 grants accounting.view and
accounting.manage together, and customers.edit projects forward from
customers.create, which migration 090 always grants alongside customers.view.
Closes#866.
Three features, all behind the `incomingInvoices` feature flag:
1. Attach the stored supplier proof PDF to the client-invoice email when a
captured invoice is re-billed/passed through, as a SEPARATE attachment so
invoice immutability holds. Global default (off), per-customer tri-state
override, and per-file selection in a new Send dialog. A missing proof at
issue time stamps inbound_documents.proof_attach_error rather than silently
dropping, and never blocks the send. Proof filename is a configurable
template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens.
2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid
with status derived from the linked invoice lifecycle rather than a
duplicated column.
3. Cross-add dialog rolling open hours and open re-bills into one invoice,
symmetric from both entry points. The two stay distinct, contiguous line
groups — never merged into shared line items.
Migration 169 is additive, hasColumn-guarded and idempotent.
Review (two rounds) closed two concerns:
- Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a
covering invoice was cancelled, so a Storno'd re-bill showed as Open in the
new panel while every billing path filters on that column being NULL — the
supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now
detaches the linkage on both invoice-cancel paths, with a regression test on
the issued-cancel path.
- Permission gating: the new controls rendered on data presence alone while
their endpoints require accounting.view / accounting.manage / customers.edit.
Now gated at both the query and render layers.
Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
Closes#969.
The cockpit's email feed rendered preview/resend/cancel/retry/send-now for every mail, consulting neither the caller's role nor their permissions, producing controls that always failed:
404 - requireOwnedQueuedEmail scopes queued mail through email_queue.event_id AND ownership of that event. CRM document mail carries no event_id; and project ownership does not imply event ownership, so a project the caller owns can hold another admin's event.
403 - preview needs events.view but the four write actions need email.send.
getProjectOverview now stamps each email with an authoritative canAct, mirroring filterOwnedEventIds; created_by is selected only for that check and stripped before the response. The cockpit reads canAct and combines it with email.send. A missing canAct reads as false.
Regression from the GHSA-93x4 fix in #960/#966, which added the ownership middleware.
Closes#968.
The roles-join fallback in adminAuth fabricated role_name='super_admin' on ANY database error, so a transient fault (connection reset, deadlock, statement timeout, pool exhaustion) silently granted super_admin for its duration. roleName is the sole discriminator for every ownership check, so this inverted the authorization model rather than failing the request.
Gate the fallback on isMissingRolesSchema(), moved to utils/dbErrors.js and shared with apiTokenAuth. The predicate was also tightened: knex prefixes the failing SQL to err.message and that SQL always names `roles`, so the old /roles/i gate was vacuous and a generic /does not exist/ could accept unrelated faults. Now trusts SQLSTATE 42P01/42703 on Postgres and exact driver phrasing on SQLite.
* fix(security): enforce project ownership (GHSA-wrg5, GHSA-93x4)
Project routes authorized on generic events.view / events.edit with NO
ownership check, so an editor-like admin could enumerate, read, update and
aggregate projects belonging to other admins' events. The project email
endpoints keyed on an email_queue id alone — any admin with events.view /
email.send could preview, resend, cancel or retry ANY queued mail by walking
ids.
The earlier 'needs a migration, deferred' assessment was wrong in one
direction and right in another: ownership IS derivable transitively via
events.project_id -> events.created_by, but only for projects that already
have a linked event. A brand-new EMPTY project has no derivable owner, which
is exactly where the create -> attach flow starts. So migration 167 adds
projects.created_by (backfilled from the single linked event owner, skipping
ambiguous multi-owner projects) and createProject finally persists the adminId
it was already being passed.
- ownedProjectIds(): union of the stored owner and the transitive path, so
pre-167 rows and new empty projects both resolve. Reads created_by
defensively so an instance that hasn't run 167 falls back to the transitive
rule instead of throwing.
- requireProjectOwnership on detail/update/attach-event/attach-quote/
attach-contract/overview; list filtered by an id allowlist (empty array
means 'owns nothing' and must return no rows, hence null-vs-[] care).
- POST /:id/events also validates the INCOMING eventId — owning the project
is not enough, or an editor could pull a foreign event in and read its
rolled-up documents via /:id/overview.
- Queued-email routes scoped via email_queue.event_id. CRM document mail has
event_id NULL and no ownable parent here, so a scoped caller is denied
rather than guessed into access. 404 (not 403) so it isn't an id oracle.
Note: adminEmail.js:315/332 let any email.view/edit admin archive or delete
any email_queue row — the same class, pre-existing and outside these two
advisories. Left untouched and reported rather than silently widened.
* fix(security): codex round 2 — make the stored project owner authoritative (GHSA-wrg5)
The first predicate union'd 'any linked event I can see' with the stored
owner, which opened two holes:
- A project owned by admin B containing ONE legacy ownerless event became
readable by every admin — and /:id/overview aggregates B's other events,
invoices and emails, so a single legacy event exposed the whole project.
- Migration 167 deliberately leaves multi-owner (ambiguous) projects NULL
rather than guessing an owner. A NULL owner was then treated as
'everyone's', so exactly those mixed projects became globally accessible.
Now: the stored created_by wins outright, and a project without a usable
stored owner only derives access when EVERY linked event is accessible (and at
least one exists). A created_by pointing at a hard-deleted admin degrades to
'no usable owner' so the project falls back to its events instead of being
locked away — no ON DELETE SET NULL migration needed. A project with neither a
usable owner nor linked events stays super_admin-only: failing closed beats
failing open, and a super_admin can reassign it.
Also returns a knex SUBQUERY rather than a materialised id list, so a large
project count can't hit the driver's bind-parameter limit.
* fix(security): codex round 3 — enforce deal-lineage ownership on project attach (GHSA-wrg5)
requireProjectOwnership vets only the DESTINATION project, while attaching a
quote or contract cascades through linkDealToProject — which re-points every
event the deal produced into that project. An editor could therefore create an
empty project of their own, attach another admin's quote, and pull that admin's
events (plus the invoices, emails and gallery that roll up with them) into a
project they own and can read via /:id/overview. The single-customer guard did
not stand in the way: an unassigned project ADOPTS the deal's customer rather
than rejecting it.
linkDealToProject now refuses to move lineage events the actor cannot own, and
assignDocument cascades BEFORE stamping the document so a refused attach leaves
nothing half-applied (the old order committed the foreign document into the
caller's project and only then declined the cascade). The quote/contract
create+update paths, which reach the same cascade with an arbitrary project_id,
thread their adminId through as well; isSuperAdmin() resolves the role for them
and fails closed when it cannot.
Events are the only ownership signal a deal carries — quotes and contracts have
no created_by in this schema — so a lineage that produced no event still cannot
be attributed. That is a property of the CRM model, noted in the code.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
* docs(security): drop the stale ownership JSDoc left by the rebase (GHSA-wrg5)
Rebasing onto main (which had gained scopeEventsQuery from #957) replayed the
round-1 doc block above round-2's replacement, leaving a comment that describes
the ORIGINAL union rule — "a project is the caller's when … it has at least one
linked event they own" — directly above the code that deliberately no longer
does that. That union is the hole round 2 closed; a comment asserting it is
worse than none.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm)
GHSA-j347 — buildCachedPayload sanitizes the operator's HTML and THEN runs
applyBrandTokens over the result with a plain String.replace, so any markup in
a token value reached the public origin unfiltered. The default templates
interpolate tokens into text AND into quoted attributes
(<img src="{{brand_logo_url}}" alt="{{company_name}} logo">,
href="mailto:{{support_email}}"), so a value could close the attribute and
inject. Token values are now HTML-escaped on substitution, mirroring
galleryOgService's escapeHtml. sanitizeBrandUrl's case-sensitive literal
'javascript:' check (which 'JavaScript:' walked straight past) is replaced by
an http/https scheme allowlist; relative logo paths are unaffected.
Writer is settings.edit (super_admin only) and the CSP blocks inline script,
so this is defence-in-depth — but sanitize-then-substitute is a real ordering
bug regardless.
GHSA-mw76 — the SSRF decline STANDS: self-hosted operators legitimately point
analytics at private addresses, so connection-time IP blocking would break real
deployments. Fixed only the narrow leak: undici strips
Authorization/Cookie/Proxy-Authorization/Host across a cross-origin redirect,
but umamiAdapter sends a CUSTOM x-umami-api-key header, which would be replayed
verbatim to the redirect target. Both adapters now use redirect: 'error'.
GHSA-29vm — the logo diagnostic echoed absolute storage roots, process.cwd()
and absolute candidate paths. It now reports candidates relative to
<STORAGE>/<CWD_STORAGE>, which answers the same 'which candidate existed'
question. It also still advertised the raw-absolute candidate that GHSA-c7x5
removed from resolveLogoFile, so it was misreporting what the resolver tries —
aligned with the real candidate list.
publicSiteService.test.js expectation updated: an '&' in a company name is now
emitted as '&'. Renders identically; the raw payload string differs.
* fix(security): codex round 2 — stop the remaining logo-path disclosure, mirror the resolver (GHSA-29vm)
- sources[].value was still echoed verbatim. branding_logo_path is stored
ABSOLUTE by multer, so relativising only resolvedTo and the candidate paths
left the filesystem layout going out anyway. It is now relativised too.
- Round 1 dropped the raw-absolute candidate on the grounds that GHSA-c7x5
removed it from resolveLogoFile — but the c7x5 follow-up RE-ADDED it (kept,
subject to the containment filter, so a legitimate multer path still
resolves). The diagnostic therefore reported every candidate as missing for
a contained absolute logo while resolvedTo named the file. It now mirrors the
resolver, containment filter included.
One deliberate cosmetic divergence, commented in place: for an absolute value
the resolver also tries path.join(root, value-minus-leading-slash), which can
never exist and would re-embed the absolute path this endpoint must stop
echoing. Omitted; every candidate that can actually match is still shown.
* fix(security): codex round 3 — mirror the resolver for root-relative logo paths (GHSA-29vm)
The logo diagnostic skipped the `<STORAGE>/<value>` candidates whenever
path.isAbsolute(value) was true. That test cannot distinguish a multer disk
path from a root-relative URL such as `/custom/logo.png`, and for the URL form
resolveLogoFile.generateCandidates() does try `<STORAGE>/custom/logo.png` and
can resolve it — so the endpoint reported "no source candidate exists" about a
logo that renders fine, and collapsed the configured value to its basename.
The stripped joins are now built unconditionally, exactly as the resolver does.
Disclosure stays closed: every candidate still passes the containment filter and
redact() rewrites survivors to `<STORAGE>/…`, never an absolute host path.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
* fix(security): gate the logo stripped-joins on containment, not isAbsolute (GHSA-29vm)
The previous commit dropped the isAbsolute() gate entirely and regressed
logoDiagnostic's own disclosure assertion: for a genuine multer disk path,
path.join(root, value-minus-leading-slash) yields
`<STORAGE>/tmp/…/storage/custom/logo.png`, and redact() only rewrites the
LEADING root — so the inner absolute path went straight back into the payload.
The right discriminator is not "is this absolute" (which cannot separate a disk
path from a root-relative URL) but "does the value already resolve inside a
storage root". If it does, it is a real disk path, the raw candidate already
covers it, and the stripped join is the double-prefixed junk that can never
exist. If it does not — the `/custom/logo.png` URL form — the stripped join is
exactly what resolveLogoFile resolves, and is shown.
Covered by a new case asserting both halves: the candidate appears for the URL
form, and the payload still contains neither the storage root nor cwd.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): enforce event ownership on the v1 API surface (GHSA-9697)
Migration 081 documents the intent — 'the token's effective permissions are
the intersection of the user's role permissions and the token's own scope
flags' — but it was never implemented.
- apiTokenAuth selected only id/username/email/role_id, so req.admin.roleName
was undefined. Every ownership helper keys on roleName, so the v1 surface
could not tell a super_admin from a demoted viewer. Now joins roles and
emits the same req.admin shape adminAuth does, including the
roles-table-missing upgrade fallback.
- No v1 route applied any ownership predicate: GET /events listed every event
on the instance, and GET /events/:id/share-link returned ANY event's
share_token — the gallery access credential, same class as GHSA-rh8r.
List is now scoped via a new scopeEventsQuery helper; the three :id routes
(detail, photo upload, share-link) use the existing requireEventOwnership.
Not a breaking change: tokens are minted by super_admins, who bypass
ownership. It closes the case where a token's owner is later demoted —
userManagementService never touches api_tokens, so the token outlived the
demotion with full read of every gallery's share token.
events.category.test.js stubbed apiTokenAuth without roleName; giving the
stub super_admin keeps requireEventOwnership from issuing a DB query and
desyncing that suite's sequenced dbMock.
* fix(security): codex round 2 — intersect v1 token scopes with role permissions (GHSA-9697)
Ownership scoping alone left half the documented control missing. Migration
081 defines a token's effective permissions as the INTERSECTION of the owner's
role permissions and the token's scope flags; requireApiScope only ever checked
the scope half. A token minted while its owner was super_admin therefore kept
write access after the owner was demoted to viewer — userManagementService
never touches api_tokens, so the token outlives the demotion, and ownership
scoping does not help because the demoted owner still owns their events.
Adds requirePermission to all six v1 routes (events.create on create,
events.view on the reads, photos.upload on upload). It keys on req.admin.id,
which apiTokenAuth already populates.
The two existing v1 suites mock the database, so a real permission lookup
500s — they now mock the permissions middleware as pass-through, matching how
they already mock apiTokenAuth. Those suites cover route logic; the
intersection is pinned by the new v1TokenPermissions suite.
* fix(security): codex round 3 — fail closed on the roles-join fallback (GHSA-9697)
The round-2 fix loaded the token owner's role so the v1 ownership checks could
tell a super_admin from a demoted viewer, and mirrored adminAuth's
roles-table-missing fallback. That fallback assigns role_name = 'super_admin',
and the catch around it was unconditional — so ANY failure of the joined query
(connection reset, deadlock, statement timeout) elevated the token owner to
super_admin as long as the simpler fallback query then succeeded. A restricted
owner could ride that into listing, reading and share-tokening every event on
the instance, which is the exact hole GHSA-9697 closes.
The fallback is now reached only for an error that genuinely names a missing
roles table/column (PG 42P01/42703 or the SQLite/MySQL wording); anything else
propagates to the 500 handler.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794)
GHSA-2qf9 — emailIntakeService downloaded, parsed and persisted every message
with no size, attachment-count or attachment-byte limit, reachable
unauthenticated by anyone who can email the operator's mailbox:
- fetch the envelope with `size` (same cheap pass) and refuse an oversized
message BEFORE downloading its source;
- cap attachment count and cumulative attachment bytes;
- limits env-overridable, defaults generous for real supplier invoices.
The teeth were in the dedup key. received_emails.message_id is varchar(512)
UNIQUE, and the failure path wrote `err-<uid>-<Date.now()>`, which can never
match the envelope-derived messageId the dedup pass compares against — so an
oversized (or overlong-Message-ID) mail was re-downloaded every poll forever,
and an OOM-kill/restart just resumed the loop. Size-skips are now recorded
under the REAL message id, and overlong ids collapse to a stable sha256 key
that always fits the column.
GHSA-pgmp / r794 — new sanitizeForLog() util (key-name deny-set, recursive,
cycle-safe) applied to the three request-body log sites in adminEvents/crud.js,
plus sanitizeValidationErrors() because express-validator's errors.array()
embeds the SUBMITTED value per field — a rejected plaintext password was still
logged. Scope is wider than filed: the update path also logged
client_password_hash and a LIVE client_share_token bearer credential.
Also: the one-time setup token was logged at warn AND printed to stdout on
every first boot, putting a live first-admin credential in combined.log,
security.log and `docker logs`. It is now written to the 0600 token file and
only surfaced when that write fails — the last-resort path it existed for.
* fix(security): codex round 3 — repair the first-run token recovery flow (GHSA-r794)
Two regressions from keeping the setup token out of the logs.
1. server.js decided whether to print the token by calling existsSync() on the
candidate path. That answers a different question than "did the write
succeed": a stale, read-only or directory-shaped SETUP_TOKEN reports as
present, so the banner suppressed the live token and pointed the operator at
content that is not it — leaving the current token only in combined.log
under default production logging. setupService now records the path the
write actually produced and exposes it via writtenSetupTokenFile().
2. The setup screen, its EN/DE strings, README, SIMPLE_SETUP and .env.example
all still told first-time users to run
`docker compose logs backend | grep -i "setup token"`. On the normal path
that command now returns a path banner and no credential, so the documented
browser-first onboarding could not be completed. They now point at
`docker compose exec backend cat /app/data/SETUP_TOKEN`, with the log
fallback described as what it is — the failure path.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): scope dashboard endpoints to the caller's events (GHSA-c2jj, gqx7, jhcf)
/dashboard/stats, /analytics and /activity are gated only by analytics.view,
which the editor role holds — but the events LIST restricts editors to their
own rows (adminEvents/crud.js: roleName === 'editor' -> created_by =
admin.id). So an editor saw instance-wide totals, and via /analytics
topGalleries other admins' gallery NAMES and SLUGS (the public gallery URL
component), for events invisible to them everywhere else.
- stats: all 10 aggregates scoped (events by id, photos/access_logs by
event_id).
- analytics: all 8 series/aggregates scoped, including topGalleries. The
external tracker device breakdown reports instance-wide data with no event
filter, so a scoped caller falls through to the access_logs heuristic
instead, which IS scoped.
- activity: feed scoped. activity_logs.event_id is nullable and the join is a
leftJoin, so system-level rows (logins, settings changes) are deliberately
excluded for a scoped caller — those are precisely the cross-admin actions
the advisory is about.
Scoping keys on 'editor' to mirror the events list exactly, so the admin
role's dashboard is unchanged. filterOwnedEventIds uses the broader
'!== super_admin' rule; the two conventions disagree in this codebase and
matching the list is the no-regression choice.
* fix(security): codex round 2 — fix activity misattribution, scope via subquery (GHSA-jhcf, c2jj, gqx7)
- expenseService passed adminId as logActivity's THIRD positional parameter,
which is eventId — so admin ids were being written into
activity_logs.event_id. The /activity scoping filter trusts that column, and
admin/event id sequences overlap, so a foreign admin's expense metadata could
surface under an editor's event. All 11 calls now pass null for eventId and
the admin as the actor, which is what they meant.
- Dashboard scoping now uses a SUBQUERY instead of pluck()+whereIn. An editor
owning more events than the driver's bind-parameter limit (~999 SQLite,
65535 Postgres) would have turned all three endpoints into 500s once each id
became a placeholder; below the limit it still re-sent the full list for each
of the ~10 aggregates per request.
Note: two billInboundNow() calls also end in ', adminId)' but have an unrelated
signature — verified untouched.
* fix(security): codex round 3 — correct legacy accounting activity rows (GHSA-jhcf)
expenseService called logActivity(type, metadata, adminId), but logActivity's
third positional parameter is eventId. Every expense / incoming-invoice entry
therefore stored the ACTING ADMIN'S ID in activity_logs.event_id.
Round 2 scoped the activity feed with
`WHERE activity_logs.event_id IN (SELECT id FROM events WHERE created_by = me)`,
which does nothing about the rows already on disk. Admin ids and event ids are
small integers from the same range, so on any upgraded instance an editor who
owns the event whose id happens to equal another admin's id is served that
admin's accounting activity, verbatim metadata included — GHSA-jhcf, still
live. Migration 168 re-attributes those rows (event_id holds exactly the actor
id that was lost) and then clears event_id so the scope predicate can no longer
match them. All ten activity types are emitted by expenseService and nothing
else, so no row with a genuine event_id is touched.
Also: the round-2 rewrite passed `{ type: 'admin', id: adminId }`
unconditionally, which stored actor_type='admin' with a null id for the
automated mailbox intake (emailIntakeService calls recordInboundDocument with
no adminId). adminActor() restores 'system' attribution for those.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): stop caller-chosen database backup destination (GHSA-jw8m)
POST /api/admin/database-backup/backup forwarded req.body straight into
databaseBackupService.backup(), which merges options over config:
const { destinationPath = '/backup/database', ... } = { ...config, ...options }
destinationPath is not a persistable setting — the /config allowlist only
accepts database_backup_* keys — so the request body was its only source.
The built-in `admin` role holds backup.create but neither settings.edit nor
backup.restore, so it could aim a full DB dump (admin bcrypt hashes, gallery
password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
(server.js mounts it with no auth middleware) and then fetch it
unauthenticated. Filed low; it is a privilege escalation to unauthenticated
disclosure.
Forward only the real knobs, and only when present so absent keys can't
override config defaults via spread.
* fix(security): backup/restore hardening — restore path allowlist, gunzip bound, manifest checksum keying (GHSA-fw4c, h652, hgp8)
- adminRestore /validate + /start: constrain caller-supplied source and
manifestPath to the operator-configured backup roots — the SAME set the
restore wizard discovers from — so disaster recovery from a rescued mount
still works, with RESTORE_ALLOWED_ROOTS as an escape hatch (GHSA-fw4c).
- restoreService.decompressFile: bound the EXPANDED size and abort the
pipeline when exceeded; default 50 GB, RESTORE_MAX_DECOMPRESSED_BYTES
overrides (GHSA-h652).
- backupManifest: BACKUP_MANIFEST_KEY upgrades new manifests to a keyed
HMAC (GHSA-hgp8). Deliberately opt-in and verify-if-present — the key
cannot live in the database because the database is inside the backup, so
a mandatory HMAC would lock operators out of the exact disaster-recovery
case this exists for.
Also fixes a pre-existing bug found while testing hgp8: the checksum passed
Object.keys().sort() as JSON.stringify's second argument, which is an array
REPLACER (a property allowlist applied at every depth), not a key sorter. All
nested keys — path, size, per-file checksum — were dropped before hashing, so
the file list sat outside the integrity check entirely and a manifest path
could be rewritten to ../../etc/passwd without disturbing the digest. Now
hashes a recursively-canonicalized copy, with the legacy serialization
accepted on validation so existing backups stay restorable.
* fix(security): codex round 2 — unbreak the restore wizard, share checksum verification, guard downgrades
- adminRestore: `source` is usually a SOURCE TYPE ('local'|'s3'|'upload'),
not a path — restoreService branches on those literals. The containment
check treated it as a path, so path.resolve('local') fell outside the
backup roots and BOTH /validate and /start returned 400, blocking every
normal restore. Type tokens are now excluded from the path check.
- backupManifest: extracted verifyManifestChecksum() as the single source of
truth for the legacy/keyed fallbacks. restoreService.performPreRestoreValidation
recomputed the digest itself with the default canonical+keyed settings,
which rejected EVERY backup written before this batch. It now delegates.
- backupManifest: guard the algorithm downgrade — with a key configured, an
attacker able to rewrite the backup store could strip checksum_algorithm,
edit the manifest and recompute a plain SHA-256 that verified. Opt-in via
BACKUP_MANIFEST_REQUIRE_KEYED so pre-key backups keep restoring by default.
* fix(security): codex round 3 — close two manifest-verification fail-opens (GHSA-hgp8)
verifyManifestChecksum returned valid for a manifest with no
verification.total_checksum at all, and restoreService only called it when
that field was present. Deleting the field was therefore a complete bypass of
the keying work: no digest check, no downgrade guard, no
BACKUP_MANIFEST_REQUIRE_KEYED. Every manifest this codebase writes stamps the
field, so an absent one now fails validation, and the call site invokes the
verifier unconditionally.
Second fail-open: the strict-mode rejection of an unkeyed manifest was gated on
`&& key`, so with BACKUP_MANIFEST_REQUIRE_KEYED=true and no BACKUP_MANIFEST_KEY
configured a plain SHA-256 manifest sailed through. Strict mode is a statement
about the operator's manifests, not about the host — it is exactly the fresh
disaster-recovery box that lacks the secret. The rejection no longer depends on
a key being present.
Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): redact gallery share tokens from analytics page-view tracking (GHSA-7m6c)
* fix(security): codex round-1 — actually disable raw auto-tracking (GHSA-7m6c)
The previous patch was inert: App.tsx passed autoTrack:true (so Umami's
data-auto-track=false was never set) and the sanitized trackPageView had no
caller (useAnalytics sits outside <Router>), so the raw token URL still hit
the collector.
- Umami: drop autoTrack:true → data-auto-track=false; page views now come
from a sanitized manual tracker.
- Rybbit: its initial-load auto pageview can't be intercepted client-side, so
use native data-mask-patterns=['/gallery/**'] to strip the token on every
auto-tracked view; skip manual tracking for it to avoid double counting.
- Mount <AnalyticsRouteTracker/> INSIDE <Router> so manual tracking runs.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): close authz/ownership gaps (secure-download binding, photo-auth+logout revocation, feedback/customer ownership, token logging)
* fix(security): codex round-1 — complete admin-token invalidation + preserve foreign assignments
- photoAuth: mirror adminAuth's active-admin lookup + iat<password_changed_at
check in the admin branch, so a deactivated admin or a pre-password-change
token can no longer fetch every photo (GHSA-x55x was only revoke+cutoff).
- adminAuth logout: revoke req.token (the token adminAuth authenticated with,
cookie OR header) instead of header-only, and clear the auth cookie — a
cookie-based logout previously left the JWT live (GHSA-cjqh).
- adminCustomers PUT /:id/events: preserve the customer's existing
assignments to events the caller does NOT own, so a restricted admin can't
revoke another admin's customer-event links via full-list replacement.
* fix(security): codex round-2 — don't 403 legit restricted-admin assignment edits
The Manage-galleries dialog submits the full initial assignment list, so a
restricted admin editing a customer that already has a foreign assignment hit
the denied.length 403 before the preservation logic ran. Reject only
NEWLY-supplied foreign/nonexistent ids; retain foreign ids the customer is
already assigned to (they can't be added or removed by a non-owner).
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): stop unauth share_token leak + block restore path-traversal, logo-path file read, branding path keys
* test: update resolveLogoFile for the c7x5 containment (reject outside-storage absolute paths, keep inside)
* fix(security): codex round-1 — escape LIKE wildcards in share-link resolve, keep in-storage absolute logos, guard restore verification
- shareLinkService: escape %/_ in the link_partial LIKE fallback so an
anonymous /resolve/____… wildcard can't match an arbitrary share_link and
leak its bearer token (reopened GHSA-rh8r). Explicit ESCAPE for SQLite.
- resolveLogoFile: re-add the raw absolute candidate but keep it subject to
the storage-root containment filter (GHSA-c7x5) so legit in-storage
absolute logos resolve while /etc/passwd stays rejected.
- restoreService: apply the same pathEscapes guard in post-restore
verification so a skipped traversal entry isn't fs.access'd/hashed.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931)
* test: pin the suffixed photo filename format in the NFD pipeline suite (#931)
* test: make the suffix-uniqueness check deterministic-in-practice (#931)
* fix(uploads): widen the anti-collision suffix to 48 bits (#931)
* fix(uploads): hide staging files from list() + share one watermark limiter process-wide (#931)
* fix(uploads): reclaim orphaned staging files + revalidate watermark settings in queued jobs (#931)
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): multi-select feedback filters + sort direction controls (#889)
* fix(gallery): keep mobile sidebar open while combining feedback filters (#889)
* fix(gallery): generic sort icon when direction is uncontrolled (#889)
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): per-event toggle to hide the logo on the password page (#894)
* fix(admin): harden login_logo_visible coercion for SQLite + string booleans (#894)
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w)
GHSA-g94x-8vv8-3c9f (HIGH) — the secure-image VIEW route
(/secure-images/:slug/secure/:photoId/:token) validated only the token
signature and took the gallery/photo from the URL, so a token minted on
any PUBLIC gallery read every other gallery's photos with no password
(its download sibling has verifyGalleryAccess; the view route can't —
it serves via <img src> with no header). Bind the token to its scope
instead: the URL photoId must equal the token's minted photoId (photos
belong to exactly one gallery, and minting is gallery-scoped), and the
gallery embedded in the token's sessionId must equal the URL gallery.
GHSA-pv6w-rj34-wj9v (MEDIUM) — GET /admin/backup/picpeak/export dumps
every table unredacted (bcrypt hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3
secrets) and was gated only by backup.create, which the built-in admin
role holds. Gate it behind super_admin, matching the restore side
(backup.restore, already admin-denied) and the masked config APIs.
Regression tests pin both: cross-gallery token reads 403 (photo and
gallery checks), backup export 403 for admin / passes for super_admin.
* test: stub requireSuperAdmin in the backup masking mock
adminBackup now calls requireSuperAdmin() at load (GHSA-pv6w export
gate), and backupSecretMasking mocks the permissions module — add the
new function to the mock so the module loads.
* fix(security): review follow-ups on the export gate (GHSA-pv6w)
- test: place the mocked export in its own mkdtemp dir. The route
recursively deletes path.dirname(filePath) after download, so a stub
in bare os.tmpdir() made the super_admin test wipe the whole temp
root — other jest workers' DB files included (latent CI flake).
- ui: hide PicpeakExportCard from non-super_admins. The role keeps
settings.view + backup.create, so after the gate its Download button
always 403'd with a generic toast; gate the card on role super_admin
to match the endpoint.
* fix(security): keep the token-mismatch audit values within varchar(20) (GHSA-g94x review)
image_access_logs.access_type is varchar(20) (migration 038), but
'token_gallery_mismatch' is 22 chars — on Postgres the audit write
threw value-too-long and logImageAccess swallowed it, so the security
event went unrecorded (the 403 still fired; log is best-effort). Shorten
to 'photo_mismatch' / 'gallery_mismatch' (14/16).
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(admin): own-property lookup in the extension MIME map (#908 review round)
A client-controlled filename ending in .constructor / .__proto__ /
.toString made EXTENSION_TO_MIME[ext] return an inherited Object.prototype
member (truthy), and the downstream extMime.startsWith threw —
a permanent 500 on the admin view for that photo instead of the JPEG /
mp4 fallback. hasOwnProperty-gated now; test pins both a .constructor
image and a .__proto__ video.
* fix(admin): drop already-expired events from the dashboard card (#909 review round)
The expiring-soon card ran Math.max(1, ceil(delta)), so an event that
expired while the dashboard sat open (its query isn't polled) showed
'1 day left' indefinitely from the stale cached row. Expired rows are
now filtered out before render; the delta is therefore always positive
and the clamp is gone.
* fix(admin): honor safe stored image MIME for auto-imported formats (#908 review round 2)
My previous round made the image side map-only to dodge the migration
039 image/jpeg backfill and image/svg+xml — but that regressed the S3
auto-importer (STORAGE_AUTO_IMPORT), which stores correct types for
avif/bmp/tiff/heic whose extensions aren't in EXTENSION_TO_MIME. Those
now served as image/jpeg (JPEG-labelled non-JPEG bytes).
Precedence is now mapped-extension (still corrects the 039 backfill on
PNGs) -> stored MIME IF in a safe raster allowlist (avif/bmp/tiff/heic
+ the mapped ones) -> image/jpeg. Allowlist, not a regex: image/svg+xml
stays excluded (scriptable inline). Tests pin avif preserved and svg
degraded to jpeg.
* fix(admin): refresh expiry status live at the boundary (#909 review round 2)
Two review findings on the admin expiry surfaces:
- The dashboard 'expiring soon' card, list badges, and detail banner are
all computed inline from Date.now() at render, so a page left open
across an event's expiry kept showing 'active'/'1 day left' until an
unrelated render — which for editor/viewer roles (no health poll)
never happens.
- My round-1 client-side filter on the dashboard desynced the visible
list from the cached total/stat ('no events expiring' beside 'view
all N').
Both are fixed by new useExpiryRefresh: it fires once at the soonest
future expiry (setTimeout, overflow-guarded). The dashboard refetches
its expiring + stats queries — the backend already excludes expired
events, so rows/total/stats come back consistent (filter removed). The
list and detail pages bump a tick so the inline badges recompute. Hooks
are placed above the loading early-returns (rules-of-hooks is disabled
in eslint, so this was a latent crash otherwise).
* fix(admin): allow any header-safe raster MIME, deny svg/xml (#908 review round 3)
The round-2 hand-listed Set kept missing formats the S3 auto-importer
stores (apng/ico/jxl beyond avif/bmp/tiff). Replace it with a regex:
honor image/<token> EXCEPT the scriptable svg / *+xml family. Covers
every current and future raster type in one rule while still blocking
inline-scriptable svg and header injection. Tests pin apng + x-icon
preserved, svg still degraded to jpeg.
* fix(admin): expiry-refresh precision + filtered refetch (#909 review round 3)
Three refinements to round-2's live-expiry work:
- useExpiryRefresh now re-arms past setTimeout's ~24.8-day overflow
limit (capped wake-up that re-evaluates) instead of dropping the timer,
so a page mounted for weeks still updates.
- The dashboard requests the expiring list ordered by expires_at asc, so
the five shown rows ARE the soonest to expire — the timer schedules
against the true next boundary even when >5 events are expiring
(getEvents gains optional sortBy/sortOrder; backend already whitelists
expires_at).
- EventsListPage refetches instead of only re-rendering at the boundary:
under the 'expiring' filter the backend drops expired rows, so a plain
tick would leave a stale 'Expired' row + total. refetch keeps rows and
totals correct under every filter.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* ci: batch stable releases into one daily version
The stable release PR was auto-merged the instant it went green, so a
day with N bugfixes produced N patch releases (3.45.8 AND 3.45.9 on
2026-07-29 alone) — N upgrade notifications for stable users and N
full Docker build cycles.
Fixes now accumulate in release-please's rolling release PR and are
cut as ONE version per day by release-stable-daily.yml (18:00 UTC).
Approval/merge mechanics are unchanged from the inline step (#719):
approve as github-actions[bot], auto-merge as the PAT so the merge
triggers the tag-cutting run.
- Urgent fix? workflow_dispatch the daily job or merge the release PR
by hand — the schedule is a default, not a gate.
- Beta is untouched: instant beta releases are load-bearing for
same-day reporter verification.
- schedule only fires from the default branch; the stable copy of the
new workflow is inert and exists to keep branches in sync.
* ci: harden the daily stable-release cut (review round)
- P1: the daily job runs on a schedule, so a fork PR can spoof the head
branch name 'release-please--branches--stable' — gh --head matches the
name only. Pin --base stable AND require isCrossRepository == false so
a fork PR can never be approved+auto-merged with the release PAT.
- P2: this scheduled job is now the ONLY automatic stable cut, so the
auto-merge-enable step no longer swallows failures (|| true); it fails
loudly and verifies autoMergeRequest is actually set. A silently
expired PAT would otherwise stop releases while the workflow stays
green. Approve stays tolerant (re-approval can return non-zero).
* ci: accept an immediately-merged release PR as success (review round 2)
gh pr merge --auto merges immediately when required checks are already
green — the normal 18:00 case, since fixes land hours earlier and CI
passes. The autoMergeRequest verify then saw null on a MERGED PR and
failed the job on the happy path. Now: MERGED = success, pending
auto-merge = success, still-open-with-no-auto-merge = real failure.
* ci: read release-PR state + auto-merge in one snapshot (review round 3)
Two separate gh pr view calls raced: a pending auto-merge completing
between them made the first read OPEN and the second read null on the
now-merged PR, failing the job on a successful release. Fetch state and
autoMergeRequest together.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
differenceInDays truncates to whole days, so an event expiring in a few
hours returned 0 and three admin surfaces treated it as gone:
- EventsListPage: status chip said 'Expired' (days <= 0) while the
public gallery — which compares real timestamps — correctly showed
'expires in X hours'. This is the reporter's exact symptom.
- EventDetailsPage: same isExpired math on the detail view.
- AdminDashboard: the expiring-soon card showed '0 days left' on the
final day.
Expired is now gated on the actual timestamp (expires_at <= now), and
the countdown chips use ceiling days so the last day reads '1 day
left' instead of flipping to Expired/0.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(admin): expose view/download counters in the admin photos list (#895 follow-up)
st-ivan's re-test after #904: statistics panel and event summary now
agree, but the per-image Engagement column still shows 0. Root cause:
the admin photos LIST endpoint maps rows to an explicit response object
that includes like/comment/rating/favorite counts but never included
view_count or download_count — the grid reads photo.view_count ?? 0,
so the column showed 0 regardless of what the DB counted. This mapper,
not stale data, is also why per-image downloads always displayed 0 in
the original report.
Suite extended with a list-endpoint assertion (beacon + download, then
the admin list reflects 1/1 and untouched photos 0/0). The skip test now
neutralizes the route's background pre-zip build, whose async ENOENT
against the intentionally missing file could land mid-suite.
* test: widen the fire-and-forget settle window (#895 follow-up)
The 100ms settle was marginal on loaded CI runners — the counter
increments are deliberately fire-and-forget, and the 909 PRs flaked on
exactly these assertions. 400ms keeps the suite fast while giving slow
runners room.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(admin): serve videos with their real MIME type in the admin photo view (#908)
The admin view route built Content-Type from the filename extension —
image/<ext> — which is invalid for videos (image/mp4). The admin player
fetches this URL into a blob that inherits the type, and browsers
refuse to play a <video> blob labeled image/*: blank/grey preview,
while download (which already uses photo.mime_type) worked fine.
Stored mime_type now wins; videos without one fall back to video/mp4,
images to the extension, and extensionless files to image/jpeg instead
of the equally invalid bare 'image/'.
Also unrefs chunkedUploadService's module-level hourly cleanup interval:
it kept Jest from exiting for any suite requiring adminPhotos (it's why
adminPhotos.reference sits on the CI ignore list). Production behavior
unchanged — the HTTP listener keeps the process alive.
New adminPhotoContentType suite pins all four MIME cases.
* fix(admin): harden admin photo Content-Type resolution (#908 review round)
External review findings, all verified:
- The header is now ALWAYS image/* or video/*. photos.mime_type is
never echoed verbatim unless it is a video/ type — the chunked-upload
path stores the client-sent MIME unvalidated, so a stored text/html
served inline under the app origin was a same-origin XSS hazard.
- MIME-less videos map from the extension via the shared
EXTENSION_TO_MIME (.mov → video/quicktime, .webm → video/webm)
instead of a blanket video/mp4 that would mislabel them.
- Images ignore the stored MIME entirely: migration 039 backfilled
image/jpeg onto every legacy row (PNGs included), so trusting it
would regress previously-correct extension-derived types. Extension
wins, normalized (jpg → image/jpeg).
Suite extended to 8 MIME cases including the XSS guard and the
039-backfill immunity.
* fix(admin): validate stored video MIME as a full header-safe token (#908 review round 2)
A prefix check let malformed client-stored values through:
'video/mp4\r\nX: y' makes res.setHeader throw ERR_INVALID_CHAR — a
permanent 500 for that photo — and a bare 'video/' is an invalid type.
Strict /^video\/[\w.+-]+$/ now gates the stored value; anything else
falls back to the extension map. Two new tests pin both shapes.
* fix(admin): map-only image Content-Type — no raw extension interpolation (#908 review round 3)
image/${ext} could synthesize image/svg+xml (scriptable when served
inline) or header-invalid values from client-controlled chunked-upload
filenames. The shared EXTENSION_TO_MIME map is now the allowlist on the
image side too; unmapped extensions serve as image/jpeg — browsers
sniff image bytes in img/blob contexts, so a mislabel is harmless where
an injected type is not.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(analytics): make per-photo view/download counters actually count (#895)
Three stacked defects behind 'per-image stats stay 0':
- photos.view_count had NO writer anywhere — the admin IMAGES table and
photo viewer display it, so it was permanently 0. It now increments
when the full-size photo or its preview tier is served, excluding the
slideshow kiosk (migration 138 design) and follow-up video Range
requests (seeks are not views). Fire-and-forget so analytics can
never fail the byte-serving path.
- Zip downloads (download-all, presigned download-all,
download-selected) never incremented per-photo download_count — only
single-photo downloads did, so zip-heavy galleries showed 0 forever.
The zip routes now bump exactly the photos that went into the archive
(the prebuilt-zip path mirrors the archive builders' category filter).
- Every admin surface used a different definition of 'downloads', which
is the reporter's 46 vs 45 vs 0: event details counted only
action='download' (no zips at all), the dashboard counted
download+download_all but silently EXCLUDED download_selected and
download_all_presigned. All queries now share one action set:
download, download_all, download_all_presigned, download_selected.
New photoEngagementCounters suite pins all of it (7 tests).
* fix(analytics): count views via an explicit lightbox beacon (#895 review round)
External review flagged that request-level view counting is wrong in
both directions: the lightbox preloads prev/next neighbours (3 fetches
per open) while a preloaded neighbour promoted by a swipe is never
re-fetched (#505 keeps the DOM node), and enhanced/maximum galleries
never hit /photo at all (bytes come from /api/secure-images).
- Views now count via POST /:slug/photo/:photoId/view, fired by the
lightbox exactly when a photo becomes the visible slide; the
serving-route increments are removed. Covers protected galleries and
the preview tier uniformly; slideshow kiosk stays excluded.
- bumpEventDownloadCounts mirrors downloadZipService._build (ALL event
photos) — the category filter mismatched the prebuilt zip's actual
contents. (That the builder ignores per-category allow_downloads is a
separate pre-existing issue.)
- Zip loops count only successfully appended entries, with a pre-append
storage stat: a lazy stream's async error bypassed the per-photo
catch and hung the whole response — pre-existing bug, now fixed.
Suite extended to 9 tests (beacon semantics, serve-does-not-count,
skipped-entry exclusion).
* fix(analytics): fire the view beacon from the premium lightbox too (#895 review round 2)
gallery-premium events use yet-another-react-lightbox inside
GalleryPremiumLayout instead of PhotoLightbox, so the layout never
counted views. yarl's on.view fires on open and on every slide change —
identical semantics to the PhotoLightbox beacon.
Also documents the accepted prebuilt-zip approximation: _build can skip
entries whose watermark step fails and still publish the archive;
counting those exactly would need a persisted zip manifest.
* perf(analytics): skip the per-entry zip preflight on S3 (#895 review round 3)
The pre-append source check exists for LocalFs's lazy createReadStream
(async error would kill the whole zip response). S3's get() awaits
GetObject and rejects inside the loop's try/catch on a missing key, so
a HEAD per entry was a redundant serial round trip — 500 extra HEADs
on a 500-photo zip.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The 3.97.0-beta.0 release PR (#899) failed its backend Tests job on
slideshowPublic.test.js: bootCrmDb's full migration chain crossed the
suite's explicit 30s beforeAll timeout argument on a slow runner. #860
raised the config default and the jest.setTimeout pins to 120s, but
hook-ARGUMENT pins override the config default and were left behind —
same time-bomb, different syntax.
Every beforeAll that boots the migration chain and pinned 30s/60s is
raised to 120000 (16 suites). Untouched on purpose: the three suites
whose pinned hooks don't run migrations (webhookDelivery,
imageProcessor.storage, storageBackend) and publicQuotes' 30s pin on
the rate-limit lockout test — neither grows with the migration chain.
No test logic changed.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(feedback): let guests remove their star rating (#884)
Clicking your current rating again clears it. rating: 0 is the wire
contract: the validator now accepts 0, and the service deletes the
guest's rating row (instead of storing a 0 that would drag the photo
average down) and recalculates photo stats. The lightbox stars send 0
on a same-star click; PhotoRating already did, but the backend rejected
it with a 400 until now.
* fix(feedback): harden the rating-clear path (#884 review round)
External review follow-ups: numerically normalize the clear sentinel so
a numeric-string "0" can't slip into the update/insert paths (validator
now also toInt()s), delete the full guest-scoped rating set on clear so
racy duplicate rows can't survive in the average (same defense as the
reaction path), and refresh the visible average/count after the
identity-modal submit path like the direct paths do.
* fix(feedback): round-2 review fixes for rating clear (#884)
- Clear sentinel matches only an explicit 0 / "0" — malformed input
(undefined, NaN, garbage strings) can no longer delete a rating.
- Lightbox survives the photo list shrinking while open (clearing your
rating under the Rated filter drops the photo on refetch): index is
re-anchored and the lightbox closes when the list empties, instead of
crashing on an out-of-range index.
- Story layout gets the same same-star-to-clear behavior, keyed off the
session-local my-rating map, and an explicit 0 no longer falls back to
displaying the photo average.
* fix(feedback): refresh guest-scoped caches after rating changes (#884 review round 3)
- GalleryView's onFeedbackChange now also invalidates ['my-feedback',
slug]: in guest identity mode the Rated/Liked filter membership and
chip counts come from that query (#538), so a cleared rating never
left the Rated filter until the 30s staleTime lapsed.
- PhotoRating invalidates gallery-photos + my-feedback on success: the
parent refetch fires optimistically in onMutate and could capture
pre-mutation state, with nothing refreshing after the server accepted.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The bottom info/action bar was a translucent gradient overlaying the
image, hiding the lower edge of the photo. The bar is now opaque and the
image area stops above it (measured via ResizeObserver, since the bar
height varies with flex-wrap, the optional filename line and safe-area
padding), so the photo is always fully visible.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Adds a fit-to-screen button next to the zoom controls (enabled while
zoomed) and double-click-to-reset on the image itself. Both snap the
photo back to 100% and re-centre it.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Clicking the black bars around the image (a missed arrow click) closed
the lightbox and dropped the guest back into the grid. The lightbox now
only closes via the X button or Escape, matching what gallery guests
expect while paging through photos.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
In simple identity mode, likes and ratings submitted from the lightbox
never called onFeedbackChange, so the gallery's photo list (whose
like_count drives the Likes/Rated feedback filter chips) stayed stale
until a full page reload. Liked photos were missing from the Likes
filter; unliked photos stayed stuck in it.
The guest-identity-mode paths and the grid PhotoCard paths already call
onFeedbackChange after submitting - the simple-mode lightbox paths were
the only ones missing it. Add the call to the three missing paths:
submitLike (simple branch), submitRating (simple branch), and the
FeedbackIdentityModal onSubmit handler.
Verified locally (Docker build of main): like a photo in the lightbox
after navigating with Next/Prev, open the Likes filter - the photo now
appears immediately with no reload, and filter contents match the admin
feedback API exactly.
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image
Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
(GHSA-r292-9mhp-454m)
Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
npm never runs in production. wait-for-db.sh now invokes the migration
runners via node directly. This ends the recurring npm-bundled-CVE
alert class; the previous 'npm install -g npm@11' line was itself a
patch for the last batch.
* fix(restore): run post-restore migrations via node — the image ships no npm
restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation.
* fix(backup): make backup settings actually apply (#871)
- Wire the What-to-Backup toggles into the walker: honor
backup_include_thumbnails / backup_include_photos (opt-out,
default ON) and accept the UI's backup_include_archives spelling
for the archived gate (the engine expected _archived, so the
Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
node-postgres returns as a string, and the S3 path concatenated it
onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
nextBackup; the UI read a field the API never sent and rendered a
hardcoded 'Not scheduled'. A named schedule label now beats the
stray default cron the UI always sent, which silently turned
weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
Thumbs.db) and honor backup_exclude_patterns in the walker
(previously rsync-only).
- Remove the compression/encryption toggles from the configuration
UI: no backend implementation exists, and collecting an encryption
passphrase while uploading plaintext is a false promise.
Closes#871
* fix(backup): close the review gaps in the settings wiring
- The UI's backup_include_archives now beats the migration-seeded
backup_include_archived: every install has the singular key seeded
true, so the alias-only-when-absent lookup made unchecking Archives
a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
and the noise filters as anchored --exclude args; previously rsync
synced the whole storage root and the walker's selection only shaped
the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
compiled to /^.nfs.*$/ whose leading dot matched any character, so
files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
(new 'skipped-by-setting' status) instead of re-implementing it
without the opt-out toggles and the archives alias.
* fix(backup): make the coverage diagnostics agree with the walker
- The coverage table shows the alias-aware flag value the gate actually
used, instead of the seeded backup_include_archived shadowed by the
UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
(backend, TS contract, summary card, EN/DE locales) so the totals
reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
fallback (include): the checkbox no longer shows 'off' while
thumbnails are being backed up, and saving an unrelated setting no
longer flips the backup scope.
* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display
- Saving a named schedule no longer wipes the stored custom cron: the
backend already prefers the label, so the cron field stays inert for
named schedules and is preserved for switching back to Custom. A
custom schedule now validates the 5-field expression before saving
(the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
include_in_default, so rsync excludes them; the enabled-only loader
hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
Boolean('false') displayed true beside a gated-off badge.
* fix(security): bump backend deps to close all open Trivy code-scanning alerts
- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)
* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9
sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.
sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.
* fix(setup): align the Node floor with the whole dependency tree and gate native updates
html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.
* fix(setup): make the update-path Node gate actually work
--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
* feat(auth): OIDC logout-to-IdP — phase 3 (#798)
RP-initiated logout behind a new oidc_logout_from_idp setting: logging
out of PicPeak also ends the IdP session. The SSO callback stores the
raw ID token in an HttpOnly cookie (also the marker that the session
came in via SSO — local-password sessions never bounce to the IdP);
/logout builds the end_session URL from discovery metadata with
id_token_hint + post_logout_redirect_uri + client_id and returns it as
ssoLogoutUrl for the frontend to navigate to. Any failure (no
end_session_endpoint, IdP unreachable, feature off) degrades to the
plain local logout.
Settings surface exposes the toggle plus the computed post-logout
redirect URI to register at the IdP. Session timeouts deliberately stay
local-only.
6 integration tests over the mock IdP; live-verified against
Keycloak 26 (logout ends the Keycloak session, no confirmation prompt).
* fix(auth): harden the SSO logout marker cookie (#798 phase 3)
Codex review round 1:
- Derive the oidc_id_token cookie options from the shared cookie policy
(COOKIE_SAMESITE / COOKIE_DOMAIN / secure resolution) — hardcoded Lax
meant split-origin deployments running on SameSite=None never sent the
marker to the cross-site /logout XHR, silently disabling logout-to-IdP.
- Oversized ID tokens (>3.9KB) now store a bare 'sso' marker instead of
no cookie, so the claimed client_id-only end-session fallback actually
happens; /logout only passes the value as id_token_hint when it is a
real JWT.
- establishAdminSession clears any stale marker on every fresh login —
sessions can die without /logout (deactivation, expiry, restore), and
a surviving marker would bounce a later local-password session to the
IdP. The SSO callback re-sets the marker for its own session.
Tests: oversized-token marker + hint-less end-session URL, stale-marker
cleared on local login; helper updated for the clear+set cookie pair.
* fix(auth): validate the logout hint against the current OIDC config (#798 phase 3)
Codex review round 2: an ID token stored at login can outlive an
issuer/client config change; sending it to the newly configured IdP as
id_token_hint strands the user on the IdP's error page (providers
validate iss/aud on the hint). buildEndSessionUrl now decodes the hint
(no verification — routing only): different issuer → skip the round-trip
entirely (the session belongs to another IdP); same issuer but changed
client → keep the round-trip, drop the unusable hint. Two tests pin both
paths.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(gallery): block password form in Instagram in-app browser (#654)
Field reports show gallery password login still failing inside
Instagram's IAB after the #656 input-attribute/trim defenses. Three
changes:
- Replace the advisory amber banner with a red blocking state: the
password form is hidden in the Instagram IAB and replaced with
platform-specific "open in external browser" instructions plus a
copy-link button (clipboard API with execCommand fallback). A
"try anyway" link restores the form as an escape hatch.
- Stop masking non-password failures as "incorrect password": a request
that never got a response (offline, webview killed it) now reports a
connection error, and a reCAPTCHA 400 reports a verification failure —
both previously fell through to the wrong-password message and sent
guests chasing the wrong cause.
- Strip invisible Unicode (zero-width chars, word joiner, BOM, soft
hyphen) from the submitted password in addition to trimming — these
ride along when the password is copy-pasted out of a chat app and fail
byte-exact bcrypt compare server-side.
* fix(gallery): retry login with typed password + honor execCommand result (#654)
Codex review round 1:
- Stored passwords can legitimately contain the invisible code points the
sanitizer strips (e.g. ZWJ emoji sequences) — creation paths don't
normalize. On a 401 where the sanitized form differs from the typed
(trimmed) input, retry once with the typed value. Skipped when a
reCAPTCHA token is in play (single-use).
- document.execCommand('copy') signals failure via its return value, not
by throwing — only show "Link copied" when it returns true.
* fix(gallery): move invisible-char password fallback server-side (#654)
Codex review round 2: the client-side retry either burned the single-use
reCAPTCHA token (making exotic-but-valid passwords impossible to enter
with reCAPTCHA on) or burned failed-attempt lockout quota on every
rescued login. Doing the fallback as a second bcrypt compare inside the
same gallery/verify request eliminates both: exact bytes are compared
first (stored passwords containing e.g. ZWJ emoji keep working), the
sanitized form only on mismatch, and trackFailedAttempt only fires when
both fail. Frontend goes back to plain trim-on-submit; the client-side
sanitizer util and retry are removed. 7 integration tests pin the
contract.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The 3.94.0-beta.0 release PR (#859) failed its backend job on
workflowEngine.test.js: bootCrmDb() runs every core migration in
beforeAll, and with migrations 163-165 merged the setup crossed the
suite's jest.setTimeout(30000) on CI runners — the log shows migration
099 still seeding after the hook timed out. Same pass is green locally
and passed on #857's rebase minutes earlier: borderline-slow, not
deterministic.
- jest.config.js: testTimeout 120000 as the default, so bootCrmDb
suites without an explicit pin stop being time bombs as the chain
grows
- every suite-level jest.setTimeout below 120s raised to 120s — local
pins OVERRIDE the config default, so the 30s/60s ones would keep
flaking regardless of the global bump
No test logic changed anywhere.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The audit #485 called for: on SQLite (native installs), timestamp
columns written with a raw `new Date()` through knex store epoch-ms
numbers; Postgres returns ISO strings. Frontend code written against
Postgres calls parseISO() on them — parseISO(number) throws and crashes
the page. #485 fixed admin Users and listed api tokens / photos /
activity as out-of-scope follow-ups.
Verified crash on main: Timeline gallery layout parseISO(uploaded_at)
against photos written by the archive-RESTORE path (raw Date). Other
raw-write surfaces (api_tokens last_used_at/revoked_at, email_queue)
degrade rather than crash but violate the ISO contract.
- extract toIso() from adminUsers.js into utils/dateNormalize.js
(contract unchanged — the 10 existing #485 tests still pin it)
- write-side: archive-restore uploaded_at, api-token last_used_at /
revoked_at, email_queue created_at/sent_at now write ISO strings
- read-side (heals existing corrupted rows): gallery /photos normalizes
uploaded_at/captured_at; api-tokens list normalizes all four
timestamp fields
- frontend defence-in-depth: Timeline layout parses uploaded_at
tolerantly (typeof guard) for stale caches / old backends
- 2 regression tests seed literal epoch numbers and assert the API
serves ISO strings
activity_logs turned out safe (created_at comes from the DB default,
not a raw Date) — left untouched.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): reveal mode — hide gallery from guests until reveal (#838)
Guests can upload during the event but see no photos until the host
reveals the gallery, manually ("Reveal now") or at a scheduled time.
- migration 165: events.reveal_mode / reveal_at / revealed_at. Effective
visibility is computed at REQUEST time (reveal_at <= now opens the
gate exactly on schedule); the minutely scheduler only stamps
revealed_at durably and emits a gallery.revealed workflow trigger
- server-side enforcement in gallery.js: /photos returns the event
shell with photos: [] + hidden_until_reveal for plain guests;
image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are
sequential — listing-only gating would be probeable); feedback-summary
gated too. Slideshow tokens (surprise beamer), client access and the
admin preview bypass; the guest upload route stays open
- admin: reveal toggle + optional scheduled datetime next to the guest
upload settings, status line and "Reveal now" button on the overview;
re-enabling the toggle clears revealed_at so a gallery can re-hide
- guest UI: upload-only view (hero, friendly message, scheduled time,
upload button) for every layout; i18n for all 8 locales
- timestamps written as ISO strings — the SQLite driver stringifies raw
Date objects into garbage; ISO round-trips on both engines
- 14 integration tests over minted gallery/slideshow/client/admin tokens
* fix(gallery): reveal/re-arm semantics + upload button i18n key (#838)
- "Reveal now" also clears a pending reveal_at: the schedule is
consumed, so the full-form admin save can't accidentally re-hide a
revealed gallery with a stale future date
- setting a FUTURE reveal_at on a revealed gallery re-arms hiding —
the one intentional way to re-hide without double-toggling the mode
- guest upload button uses the existing upload.uploadPhotos key
(gallery.uploadPhotos never existed; the button showed EN everywhere)
* fix(gallery): close reveal bypasses from review round 1 (#838)
- the hero-derivative route and the secure-images token-mint +
secure-download routes are now reveal-gated: hero serves a 1920px
derivative of ANY sequential photo id and secure tokens fetch
originals — both were open bypasses while hidden. blockHiddenGallery
moved to utils/revealMode.js and shared
- customer-portal tokens (via:'customer', no accessLevel) now bypass
reveal mode — they are the host/customer, not a guest, and were
getting the upload-only view
- an open hidden guest view refetches exactly at reveal_at plus a 60s
fallback poll, so the gallery appears without a manual reload
- gallery.revealed added to the workflow editor's trigger picker so
the advertised notification hook is reachable in the UI
- migration 165 guards each column independently (partial-state safe)
* fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838)
- legacy /api/images router reveal-gated (view, secure-token + signed-url
minting), and the signed-URL SERVE path re-checks hidden state via a
backward-compatible bypass flag in the token payload
- secure-image tokens record revealBypass at mint and are re-validated
at serve time — a re-hide kills in-flight guest tokens within the
request, while slideshow/client tokens keep working
- OG metadata and the unauthenticated /og cover fall back to the brand
logo / 404 while hidden — no hero-photo spoiler for social crawlers
- photo-feedback GET/POST reveal-gated (sequential ids were enumerable);
/my-feedback returns the empty back-compat shape (rows leak filename +
storage path)
- the reveal scheduler skips drafts — no premature stamp/notification
for unpublished galleries
- emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters
pass the reveal timestamp so a re-hidden gallery's second reveal
fires workflows again instead of deduping into silence
* fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838)
- the scheduler now consumes reveal_at when stamping (matching "Reveal
now"), and re-arming via a partial API update clears a stale PAST
schedule — previously {reveal_mode:true} without reveal_at could
instantly re-open the gate through the leftover date
- /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s
poll while the mode is on — a re-hide now propagates to open clients
in both directions, not just hidden→visible
Codex round-3 claim about timestamp-without-timezone drift on non-UTC
Postgres was verified FALSE: knex's table.timestamp() creates
timestamptz on PG (confirmed via information_schema on a live install),
which stores absolute instants regardless of server TZ.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(feedback): emoji reactions on photos (#839)
Per-photo emoji reactions from a fixed curated set (❤️😂😍👏🎉),
one reaction per guest per photo — same emoji toggles off, another
switches in place. Stored as feedback_type='reaction' rows with per-guest
scoping identical to likes (guest_id when present, device hash otherwise).
- migration 164: allow_reactions toggle (default on, still gated by the
opt-in feedback_enabled master switch), photo_feedback.reaction value
column, denormalized photos.reaction_count
- emoji whitelist enforced in the route validator AND the service
(shared constants/reactions.js, mirrored in the frontend)
- per-emoji tallies + my_feedback.reaction in the photo feedback
endpoint; hidden-by-moderator reactions leave all counts
- reactions ride the existing rate limiting (like-tier), guest identity
modes, and moderation actions; long + pivot exports carry the emoji
- gallery: reaction bar in the photo feedback panel (grid lightbox);
admin: allow_reactions toggle next to likes, analytics tile,
create/duplicate event paths
- i18n for all 8 locales; 9 service-level tests
* fix(feedback): reach reactions without comments; numeric analytics totals (#839)
- the lightbox feedback-panel toggle was gated on allow_comments only —
with comments off the new reaction bar was unreachable; the gate now
opens for comments OR reactions
- the analytics summary now coerces Postgres string counts to numbers:
total_feedback concatenated instead of adding ("00006")
* fix(feedback): harden reactions from review round 1 (#839)
- per-emoji tallies are gated on show_feedback_to_guests — with sharing
off a guest sees only their own selection, no aggregate counts
- reaction toggle/switch operate on the guest-scoped row SET, so rows
duplicated by the (like-parity) check-then-insert race collapse on the
next interaction instead of counting twice
- rate-limit defaults merge UNDER the persisted settings object —
stored rows predating the reaction key otherwise dropped it to the
generic 100/h fallback
- optimistic revert uses the pre-mutation value via mutation context;
the onError closure sees the post-optimistic render, so the old
revert froze the wrong state on failed toggles
* fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839)
- summary.reaction_count is gated on show_feedback_to_guests like the
per-emoji map, keeping the "no aggregates while sharing is off"
promise consistent
- the admin feedback list renders the reaction emoji on reaction rows
and the type filter gains a Reactions option (7 locales; es has no
types block and falls back to EN defaults)
* fix(feedback): register reaction activity types with translated labels (#839)
photo_reaction / guest_feedback_reaction are logged by the submission
paths but were absent from the frontend activity-type union and the
admin.activities label maps — the recent-activity feed would have shown
the raw identifiers. All 8 locales.
* feat(feedback): reactions in guest CRM and the premium gallery layout (#839)
- guest CRM: per-guest reaction counts in the list aggregation and a
Reacted tab (photo grid with emoji badges) + stats card in the guest
detail modal; picks/aggregate/exports stay selection-only by design
- premium layout: its own yet-another-react-lightbox now gets a fixed
reaction-bar overlay (per-photo fetch, optimistic switch) — reactions
were otherwise unreachable in this layout since it bypasses the
shared PhotoLightbox
- allowReactions threaded through the layout feedbackOptions; guest
i18n keys for the 7 locales that carry the guests block
* fix(feedback): portal the premium reaction bar to document.body (#839)
Inside the layout tree an ancestor stacking context (framer-motion
transforms) painted the bar under yarl's body-level portal — visible
but unclickable, every tap landed on the slide image. As a direct body
child the z-index 10000 genuinely wins over yarl's 9999. Verified by
clicking through in the running app.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(auth): OIDC role mapping + login policy — phase 2 (#798)
Role mapping: configurable dot-path roles claim (Keycloak realm_access.roles,
Authentik/Pocket ID groups, Entra roles), IdP-value → PicPeak-role mapping
table validated against the roles table, re-evaluated on every SSO login with
highest-priority-wins on multiple matches. The last active super_admin is
never demoted. Optional require-mapped-role policy refuses logins whose token
maps to no role (sso_error=no_role).
Login policy: oidc_disable_local_login makes the API refuse password logins
(403 LOCAL_LOGIN_DISABLED) and the login page render SSO-only; only effective
while SSO is enabled+configured, and OIDC_BREAK_GLASS=true always re-opens
local login. Public settings expose the EFFECTIVE flag only.
Settings UI: Role-mapping card (claim path, mapping rows editor, strict
toggle) and Login-policy card with break-glass hint, EN+DE.
14 new integration tests over the mock IdP.
* fix(auth): harden phase-2 review findings (#798)
- memoize the scrypt-derived OIDC key and serve /public/settings from a
10s-TTL flag cache — the unauthenticated endpoint no longer pays a
13-key config read + blocking scryptSync per request (login route
still checks uncached)
- make the last-super-admin demotion guard atomic (FOR UPDATE on the
active super rows) — concurrent mapped callbacks could previously
both count 2 and demote both supers
- own-property lookup in role mapping: IdP values like `constructor`
now count as unmapped instead of corrupting the roles query
- SsoTab clears oidc_disable_local_login in the same save that turns
SSO off — the full-form payload otherwise hit the server-side 400
* fix(auth): guarantee break-glass reachability for SSO-only mode (#798)
- wire OIDC_BREAK_GLASS and OIDC_ENCRYPTION_KEY through the quick-start
docker-compose.yml env allowlist (production compose already passes
.env via env_file) and document both in .env.example
- refuse enabling oidc_disable_local_login unless an active
local-password super_admin exists: OIDC_BREAK_GLASS only re-opens the
password route, which OIDC-owned accounts can never use, and
settings.edit is super_admin-only — an all-OIDC instance would be
unrecoverable during an IdP outage
* fix(auth): close SSO-only lockout gaps from review round 3 (#798)
- role sync never demotes the last active LOCAL-password super_admin
(an OIDC-owned super does not count as break-glass), and
isLocalLoginDisabled() disarms itself when no such account remains —
self-healing against manual demotion/deactivation/deletion paths
- the local-super save-time check now validates the MERGED state, so
re-enabling SSO with a stored disable flag is checked too
- ALL oidc_* keys are reserved from the generic settings upserts/reads
(prefix match) — policy and mapping invariants can only go through
the validated PUT /sso
- /admin/login/mfa re-checks the policy so an mfa_pending token minted
before the flip cannot complete into a local session
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(crm): pass trx to logActivity inside transactions — audit rows were silently lost on SQLite
createContract, updateContract, createStorno and reissueInvoice called
logActivity() (contract paths also adminActor()) from inside a knex
transaction without the trx executor — the pattern db.js:648's comment
explicitly warns about. On single-connection SQLite the audit insert
waits on a second pool connection while the trx holds the only one:
a 60s acquire-timeout stall per call, then logActivity's catch swallows
the failure and the audit row is silently lost. Postgres unaffected.
Fix mirrors the one call site that already did it right
(contract_created_from_quote, conversions.js): resolve the audit actor
before the transaction opens and pass trx as logActivity's executor so
the insert rides the transaction's connection.
Verified NOT affected (logActivity outside any trx, unchanged):
cancelContract, contract_converted_to_event, contract_signed_by_customer,
contract_sent, invoice_sent/_cancelled(draft)/_released/monthly_bill.
Found by the #587 integration-test work (PR #850, which shrank the pool
acquire timeout to tolerate the stall — that workaround can be dropped
once both land).
* fix(crm): run reissueInvoice's createInvoice without a wrapping transaction (codex review of #851)
The round-1 fix passed trx to the reissue audit call — but that point
was never reached on single-connection SQLite: createInvoice internally
reads via the global connection (businessProfileService.getProfile,
getAppSetting, bank-account resolution), so the outer trx deadlocked
first and aborted the replacement AFTER the Storno had already
committed and been emailed.
createInvoice's five other callers all run it without a trx; reissue
now does the same and backlinks afterwards. Trade-off documented in
code: replacement + backlink are no longer atomic — a crash between
them leaves a visible draft without replaces_invoice_id, which beats
the guaranteed stall. New regression test drives a full cancel+reissue
on the SQLite harness and pins the invoice_reissued audit row.
* fix(crm): restore the reissue transaction by routing createInvoice's reads through trx (codex review of #851, round 2)
Round 2 was right that dropping the wrapping transaction traded the
deadlock for orphan drafts: createInvoice inserts the invoice row and
claims a sequence number BEFORE line-item validation can throw, so a
failed reissue would persist partial state after the Storno committed.
Proper fix: the transaction is back, and every read inside createInvoice
now rides it — getProfile and resolveBankAccountForCurrency gained an
optional conn param (default db, all other callers unchanged),
getAppSetting calls pass trx (crm_invoice_round_total + the
resolveNetDays default the regression test flushed out), and the
invoice_created audit uses the trx executor. The reissue regression test
now proves a full cancel+reissue commits atomically on single-connection
SQLite.
* test(crm): mint-path integration tests — quote send, invoice storno, contract countersign (#587)
End-to-end through the real HTTP → route → service → DB → email-queue →
file pipeline on full-migration SQLite (helpers/crmDb), real pdfkit/
pdf-lib rendering, no mock-fs, no network. 7 tests.
Deviations from the issue spec — the tests pin the code's real behavior:
- Storno route is POST /:id/cancel (not /:id/storno), responds 200 with
{ cancelled, stornoId } (not 201).
- Quote re-send rejects with 409 (not 400).
- Contract statuses are signed_by_customer → fully_signed; the hash
columns are pdf_sha256 / signed_pdf_sha256 (no integrity_hash) — the
test verifies the stored sha256 against the file on disk.
- Business-doc PDFs persist under process.cwd()/storage/business-docs,
not STORAGE_PATH — isolated via chdir into the temp dir.
Two documented, test-scoped harness workarounds: shrunk pool acquire
timeout (guards against the pre-existing logActivity-inside-transaction
deadlock in createContract/createStorno on single-connection SQLite —
worth its own fix) and Date→ISO binding normalization (node-sqlite3's
cross-realm Date detection under jest's vm sandbox).
Assisted-by: task agent (worktree)
* test(crm): pin sendStorno side effects + real customer-sign flow (codex review of #850)
- Storno test now asserts the delivery leg cancelInvoice deliberately
swallows on failure: storno status 'sent', PDF on disk, storno_issued
email queued to the customer — a broken render/persist/queue no
longer stays green.
- Contract seed goes through sendContract's token + a real
recordCustomerSignature instead of a direct status UPDATE, so
countersign exercises the signature-layering path; the test now also
pins that the customer's signature asset survives countersigning.
* test(crm): prove both signature stamps are embedded in the countersigned PDF (codex review of #850, round 2)
Path/hash assertions alone stay green if countersign stamps the admin
onto the unsigned base PDF. New pdf-lib helper counts embedded image
XObjects per page of the final document and asserts the signature page
carries at least two — customer stamp AND admin stamp.
* feat(notifications): surface guest activity in the admin bell (#746)
Favorites already reached activity_logs (feedbackService), but gallery
opens and downloads only landed in access_logs — invisible in the
notification bell. Now:
- gallery_opened on the guest photo-list route, debounced in-memory to
one notification per event per 6h (the endpoint fires per page load;
per-hit notifications would spam the bell). Slideshow traffic stays
excluded, matching the analytics exclusion.
- gallery_downloaded on all four download paths (streamed + pre-zipped +
presigned download-all, download-selected) with scope metadata.
- Frontend: locale entries for galleryOpened/galleryDownloaded (and
photoFavorite, which previously fell through to the generic 'system
activity' line) in all 8 languages — resolved via the existing smart
camelCase fallback, no switch cases needed. Distinct bell icons per
type.
* fix(notifications): single-photo download activity + render per-type bell icons (codex review of #849)
- The per-photo Save route (GET /:slug/download/:photoId) only wrote to
access_logs — the most common download path never reached the bell.
Now emits gallery_downloaded with scope 'single', debounced to one
notification per event per hour: a guest saving 30 photos is one
signal, not thirty (exact counts stay in access_logs/analytics).
- getNotificationStyle's icon names were dead — AdminHeader hard-coded
<Bell> for every row. Added an icon map so gallery opens (Eye),
downloads (Download), favorites (Heart) and the pre-existing style
names render their intended icons.
* fix(notifications): notify after successful delivery, complete the icon map (codex review of #849, round 2)
- Single-photo notification now fires on res 'finish' with status < 400:
emitting up-front logged downloads that then 404ed/failed AND burned
the 1h debounce window against the next real download.
- Icon map completed over every name getNotificationStyle returns
(grep-verified) — settings/user/mail/etc. styles render their declared
icons instead of falling back to Bell.
Deliberately NOT taken from the review: DB-backed debounce state for
multi-worker deployments. The backend's current deployment contract is
single-process (no PM2 cluster in-repo; multi-replica explicitly parked
in #799 — chunked-upload/session state is process-local for the same
reason). Worst case under a future multi-worker setup is N notifications
per window, which degrades, not breaks; a shared-store debounce belongs
to the #799 phase-3 work.
* fix(notifications): attribute client sessions, log cached-ZIP after finish, add Trash2 icon (codex review of #849, round 3)
- gallery_opened/gallery_downloaded now carry the real actor: client
sessions (accessLevel 'client') are recorded as 'customer' instead of
being mislabeled 'guest' — #746 explicitly covers client activity, so
they are attributed, not excluded.
- Cached-ZIP streaming path logs on res 'finish' (< 400) like the
single-photo path — piping is not delivery. The presigned-redirect
and on-the-fly-archiver paths keep their existing timing (redirect
handoff / post-finalize).
- Trash2 added to the icon map (customer_erased, bulk_delete_completed
no longer fall back to Bell — the grep that built the map missed the
digit in the name).
* fix(notifications): dashboard formatting, portal dedup, actor-aware wording, archiver finish-hooks (codex review of #849, confirmation round)
- activity_logs feed TWO surfaces: the dashboard's Recent Activity used
admin.activities.<type> keys that didn't exist, rendering raw
identifiers — added gallery_opened/gallery_downloaded entries in all
8 locales.
- Customer-portal opens already log customer_event_access at the
access-token mint; the ensuing /photos call no longer double-notifies
(client sessions surface via downloads only).
- gallery_downloaded formatting is actor-aware: customer sessions render
'Customer downloaded…' (new galleryDownloadedCustomer key ×8) instead
of 'A guest…'.
- Both on-the-fly ZIP paths (download-all fallback + download-selected)
notify on res 'finish' < 400 — archive.finalize() ends Archiver's
input, not the HTTP transfer.
* fix(notifications): key customer dedup/attribution on portal provenance, neutral favorite wording (codex review of #849, final round)
The previous dedup was inverted: portal-minted tokens carry
via:'customer' but NO accessLevel (they run as guest), while PIN-client
logins carry accessLevel:'client' and log nothing else. So PIN clients'
only open signal was suppressed while portal opens still double-
notified and portal downloads read as guest activity.
verifyGalleryAccess now surfaces req.viaCustomer; gallery_opened dedups
on THAT (portal only), and galleryActor treats via-customer OR
accessLevel-client as 'customer'. photoFavorite wording is actor-neutral
across all 8 locales — feedbackService logs favorites without an actor,
so claiming 'a guest' was wrong for customer favorites.
* feat(slideshow): guest-scannable share-link QR overlay (#837)
- Global settings (Settings → Slideshow): slideshow_qr_enabled/position/
opacity/size — same option shape and cascade as the watermark.
- Per-event tri-state show_qr (migration 163): NULL inherits the global,
true/false force on/off; editable in the per-event slideshow card.
- State endpoint ships the QR as a PNG data URI (cached per share URL —
the 3s projector poll never re-encodes), so the kiosk needs no QR lib
and no extra authenticated request.
- Kiosk renders the QR in a white padded corner box so it stays
scannable on any photo.
- i18n: en + de (the slideshow namespace has no other locales yet).
* fix(slideshow): persist per-event QR override, show QR on empty shows, bound the QR cache (codex review of #848)
- OverviewTab never passed event.show_qr into the settings card (and the
Event type lacked the field), so a stored true/false override always
displayed as 'inherit' and the next save silently reset it to NULL.
- The QR overlay was nested inside the photos.length > 0 branch — an
empty or category-filtered live gallery showed only 'Waiting for
photos', exactly when 'scan to add the first photos' matters most.
Now rendered for any running show.
- slideshowQrCache: insertion-order eviction at 50 entries — rotated
tokens and past events no longer accumulate base64 PNGs forever.
* fix(slideshow): derive the QR origin from the kiosk request when the base is loopback (codex review of #848, round 2)
With the compose-default FRONTEND_URL=http://localhost:3000 (or no base
configured) the overlay QR sent scanning phones to their own localhost.
The state poll comes from the kiosk browser itself, so its Host header +
protocol (trust proxy is configured) are exactly the public origin
guests can reach — used whenever the configured base is missing or
loopback. Mirrors the ?origin= fallback #847 uses for the admin-side
QR downloads.
* fix(slideshow): kiosk passes its origin for the QR fallback (codex review of #848, round 3)
req.get('host') is not the browser origin behind the standard proxies —
frontend/nginx.conf forwards $host with the port stripped, so a compose
LAN deployment on :3000 encoded port 80. The kiosk now sends
window.location.origin with the session/state calls (validated
server-side, same pattern as #847's admin downloads); the Host-derived
origin remains as second fallback.
* fix(slideshow): reject loopback kiosk origins, throttle QR regeneration per event (codex review of #848, confirmation round)
- A loopback window.location.origin from the kiosk is no more
guest-reachable than the loopback base it would replace — rejected;
when no reachable URL remains the overlay is suppressed entirely (no
QR beats a QR that sends phones to their own localhost). New test
pins the suppression.
- The QR cache is keyed by event id with a 60s regeneration throttle:
the origin is caller-influenced when the base is loopback, so
URL-keyed caching let a slideshow-link holder force a fresh
QRCode.toDataURL per request via unique origins — a cheap CPU
exhaustion path. Encode rate is now bounded per event regardless of
input. QR margin also raised to the 4-module spec quiet zone,
matching #847.
* fix(slideshow): never serve a mismatched cached QR + single-flight encoding (codex review of #848, final round)
- A slideshow-token holder could poison the projector's QR: an
attacker-origin entry cached per event was served to the legitimate
kiosk for the rest of the throttle window. A cached artifact is now
only served when its URL matches the request; mismatches inside the
window suppress the overlay briefly instead of showing foreign
content.
- Cold-cache stampede closed: concurrent polls share one in-flight
encode promise instead of each scheduling a 512px render.
Rejected from the same round (false positive, verified empirically):
the loopback regex claim — /^https?:\/\/(localhost|127\.)/ matches
'http://localhost:3000' and '127.0.0.1:port' just fine (no trailing
slash required), and the suppression test runs green.
* feat(events): gallery QR code + printable table-card/poster PDFs (#836)
- GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or
SVG, inline or attachment; adminAuth + events.view + ownership.
- GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card /
A4 poster with event name, QR, localized caption (8 locales; Cyrillic
falls back to English — built-in Helvetica has no Cyrillic glyphs) and
the share URL as footer.
- Event detail: QR section in ShareLinkCard with live preview (blob
fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print
language follows the admin UI language. i18n keys in all 8 locales.
- qrcode + pdfkit were already dependencies (MFA / CRM PDFs).
* fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847)
- QR URLs: prefer the configured public base, but fall back to the admin
browser's origin (passed as ?origin=, validated) when the base is
missing or localhost — mirrors buildShareLinkUrl so the QR encodes the
same URL the card displays instead of an unusable localhost target.
- PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek)
instead of WinAnsi-only Helvetica: Cyrillic event names no longer
silently disappear, and the caption's English-fallback hack is gone.
- Fixed vertical layout: title gets a bounded two-line ellipsis region
and all positions derive from constants, so long event names can't
push the QR/caption over the footer; URL footer bounded too.
- ShareLinkCard preview: stale-response guard — a late blob response
after unmount/event-switch is revoked instead of leaking and
overwriting the newer event's QR.
* fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2)
Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files
are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix
Cyrillic titles and regressed the ru caption. Now bundling the complete
IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap
verified via fontkit, rendering verified on a generated PDF) under
assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside.
~400 KB total; source: IBM/plex release zip @ibm/plex-sans@1.1.0.
Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a
FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like
the frontend's own URL logic does.
Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only
subsets and share the Cyrillic gap.
* fix(events): responsive QR card that survives preview failures (codex review of #847, round 3)
- The QR section keys off share-link availability instead of a loaded
preview: a transient failure of the preview request no longer hides
every download button until reload; a placeholder tile renders in
place of the image.
- Preview + actions stack on phone widths and the button grid drops to
one column below sm, so 'Tischkarte (A6)'-length labels don't
overflow.
* fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round)
- The QR target is now the STORED share_link — exactly what the card
displays and the admin copies. Rebuilding from current slug/token/
short-URL setting could diverge for legacy absolute links or events
created under a different short-URL setting; a printed QR encoding a
different URL than the card is a permanent mistake. Rebuild remains
only as fallback when no share_link is stored.
- QR margin back to the library's 4-module default for all generated
assets — the spec's quiet zone; margin 2 risks scan failures when the
printout sits against colored surroundings.
* fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round)
Quote-/contract-converted events persist share_link as the raw token —
the frontend's buildShareLinkUrl prefixes those with /gallery/, but the
QR path normalization only added a leading slash, encoding
<origin>/<token> into every image/PDF for such events. Now mirrors the
frontend exactly.
* test(events): 30s timeout for the print-PDF cases (CI fix)
The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each);
font parsing + subsetting exceeds jest's 5s default on slower CI
runners — the suite went red on exactly that test after the font
commit.
* fix(file-watcher): bound concurrent photo processing
chokidar fires 'add' once per file — with no ignoreInitial option the
boot scan fires it for every existing file, and a bulk drop into the
watch folder fires it for every new one at once. Each handler runs DB
lookups plus (for new files) a full sharp pipeline; sharp.concurrency(2)
only caps libvips threads WITHIN one operation, not the number of
parallel pipelines, so unbounded handlers can OOM small hosts.
Gate both 'add' and 'unlink' through a shared p-limit
(FILE_WATCHER_CONCURRENCY, default 2, floor 1) — mass deletes otherwise
burst DB work and ZIP-cache invalidation the same way. p-limit is pinned
to ^3.1.0, the last CommonJS release.
Adapted from the filpgame fork (426ca491) — thanks @filpgame; extended
to cover 'unlink', documented in .env.example, plus a lock-in test for
the existing Sharp cache/concurrency caps this bound relies on.
* chore(compose): pass FILE_WATCHER_CONCURRENCY into the backend container (codex review of #846)
The backend service uses an explicit environment list (no env_file), so
the documented override never reached the container in the default
compose deployments. Added to both compose files + root .env.example.
* fix(uploads): keep videos when thumbnail generation fails
processUploadedVideo() (ffmpeg probe + thumbnail) was unguarded in both
pipeline paths, while the image branch next to each already survives its
thumbnail failures:
- processUploadedPhotos (sync): the throw failed the whole upload — the
video was lost.
- processPhoto (async worker, the path real uploads take): the throw
marked the row 'failed', and the guest gallery only lists 'complete' —
the video became permanently invisible despite being fully uploaded.
Both call sites now fall back to extractVideoMetadata() alone and keep
the video without a preview; if even the probe fails, the video is kept
with no metadata. Idea from the munin92 fork (2026-07-02), reimplemented
for both paths + regression test.
* fix(uploads): placeholder thumbnail for rescued videos (codex review of #845)
A completed video with a NULL thumbnail made the gallery grid fetch the
ORIGINAL video file as an <img> blob (thumbnail_url || url) — a
potentially multi-GB download for a broken tile. Both fallback paths now
generate the existing sharp-rendered play-button placeholder
(generateVideoPlaceholder — ffmpeg-free), so rescued videos get a real
tile. Test asserts the placeholder key lands in thumbnail_path.
* fix(security): read the password-complexity key the settings UI writes
The settings UI saves the admin's complexity choice as
security_password_complexity (useSettingsState.ts prefixes security_ to
password_complexity), but getPasswordComplexitySettings() queried
security_password_complexity_level — written by nothing — so the setting
was silently ignored and password validation always used the 'moderate'
default. Spotted in the filpgame fork (their main, 2026-07-14).
* fix(security): accept the Postgres json-column shape of the complexity value (codex review of #843)
On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
Follow-ups from the codex review of #834:
- .gitignore: backend/storage/ is runtime-generated (media, previews,
thumbnails, business docs) and was only partially ignored — E2E runs
left it dangling as untracked, which is how ~12 MB of artifacts nearly
landed in a commit. Ignore the whole directory (nothing under it is
tracked); replaces the narrower business-docs rule.
- backend/.dockerignore: the granular storage/* rules missed
storage/previews, so locally generated previews were copied into
production images. Exclude storage entirely — the Dockerfile creates
the needed directories itself (RUN mkdir -p, Dockerfile:96).
- fileSecurityUtils.js: remove getSafeFilename — zero callers across the
repo, and its private extension whitelist silently drifted from the
real validation paths (see #834), which is exactly the trap dead
security code sets.
- getFrontendExtensionMap now tolerates quoted keys and trailing comments
and throws on any other unparsable map line, so future syntax drift fails
loudly instead of silently dropping entries from the comparison.
- Revert the .dng/.heic/.heif addition to getSafeFilename: the helper has
no callers, so the edit was dead code. Live validation paths already
cover these formats.
- Derivative key collision: processUploadedPhotos/replacePhoto passed the
client-supplied original filename as the RAW output basename, but thumbnails/
heroes/previews are global keys — two galleries uploading IMG_0001.dng would
overwrite each other's derivative. Use the unique stored newFilename instead.
(processPhoto already used the unique photo.filename.)
- Watermark: the watermark path opens the original with sharp, which can't decode
RAW, so it fell back to the original bytes and recorded the copy as watermarked.
Skip RAW in generateForPhoto (like videos) so the watermark state stays honest
until RAW watermarking is properly supported.
- exiftool added to Dockerfile.dev so dev/native runtimes don't accept a DNG then
fail it with ENOENT.
The RAW/DNG extraction was only wired into processUploadedPhotos() (the
synchronous path), but real uploads queue to 'pending' and are handled by the
background worker → processPhoto(), which generated the thumbnail + dimensions
directly from the DNG (both fail) and then marked the photo 'complete' — success
with no thumbnail. Wire withProcessableImage() into processPhoto() (the live
path) and into photoReplacementService.replacePhoto() (replace-by-name), so all
three ingest paths extract the embedded JPEG preview for RAW.
Updates the processPhoto test's imageProcessor mock with the new
withProcessableImage dependency (pass-through for ordinary images).
The lightbox falls back to photo.url (the ORIGINAL) when preview_url is null,
which happens by default (lightbox_preview_enabled=false). For HEIC/HEIF/DNG the
original bytes aren't renderable in an <img>, so the lightbox showed a broken
image. Now force preview_url for those formats (by MIME or extension) regardless
of the toggle, so the browser always gets the generated JPEG preview. Covers DNG
too (forward-compatible with #833).
EXPERIMENTAL caveat unchanged: whether the preview actually renders still depends
on the backend decoding the source — HEVC-in-HEIC on the prod Alpine image is
unverified, DNG needs exiftool (#833). Documented on the PR.
The magic-number check in validateFileContent uses .every(), so the two
endianness entries (II + MM) could never both match — an admin DNG upload would
be rejected at content validation. Use the little-endian II magic only (Apple
ProRAW / camera DNGs); a rare big-endian DNG fails the check and is rejected,
which is safe since the embedded-preview extraction validates real content.
Two findings from the Codex review:
- validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither
image/heic nor image/heif — so HEIC was rejected before sharp ever saw it,
despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp'
(offset 4) magic number (the check is .every, so alternatives can't be
separate entries).
- Changing the shared upload.fileRequirements string to interpolate {{formats}}
left the admin PhotoUpload caller passing only { limit }, rendering the
placeholder literally (it was also already dropping {{sizeLimit}} from #823).
The admin caller now passes formats + sizeLimit + limit, from the admin
settings it already loads.
Sharp's bundled libvips has no raw loader, so a DNG can't be thumbnailed
directly. This adds a preview-extraction step so RAW/DNG uploads get a proper
thumbnail + gallery preview while the original RAW is kept for download.
- imageProcessor: isRawFilename() + extractRawPreview() (exiftool extracts the
embedded full-res JPEG — JpgFromRaw → PreviewImage → ThumbnailImage, validated
with sharp) + withProcessableImage() which is a pass-through for ordinary
images and swaps in the extracted JPEG for RAW. Wired into ingest
(photoProcessor) and all three on-demand generators (ensureThumbnail/Hero/
Preview). generateHeroImage/generatePreviewImage gained outputBasename so
RAW-derived outputs stay named after the source.
- Dockerfile: add exiftool (confirmed present in Alpine v3.24 community).
- Format maps: dng → image/x-adobe-dng in uploadSettings.js and fileTypes.ts;
ALLOWED_MEDIA_TYPES gains a DNG entry (TIFF magic numbers) so it passes the
security file-validator.
Strictly gated by extension: nothing in this path runs for jpg/png/webp/etc, so
existing photos are unaffected. If extraction fails (corrupt RAW, no embedded
preview), the photo is marked 'failed' with a clear error — same as any
unreadable upload.
Verification boundary (please validate on a real DNG after the image rebuilds):
the exiftool extraction itself couldn't be exercised in the dev sandbox
(exiftool isn't a dev dependency and there's no DNG fixture). Unit tests cover
the gating (RAW detection + non-RAW pass-through + clean failure without
exiftool); existing processPhoto tests still pass. Known limitation: a DNG is
only accepted when the browser reports its MIME as image/x-adobe-dng (Chrome
does); browsers that send an empty type reject it client- and server-side —
a follow-up can add extension-based acceptance for the RAW set.
Companion to the HEIC/dynamic-hint PR; targets main only.
Two of the three things from #821:
- HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif`
input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips
8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both
the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which
are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection,
but a genuine .heic upload is now handled when it arrives.)
- The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New
extensionsToLabel() renders the actually-configured, supported formats (e.g.
"JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}}
across all 8 locales. Unsupported extensions are dropped from the label so it
never advertises a format the backend would reject.
DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader,
so a DNG would upload then fail thumbnailing (photo → 'failed', no preview).
Proper RAW support (embedded-preview extraction) is a separate PR.
Adds vitest coverage for extensionsToLabel + the HEIC mapping.
Three follow-ups from the Codex review of #823:
1. PublicSettings TypeScript interface was missing general_max_file_size_mb,
so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI
didn't catch it because the pipeline runs `build` (esbuild, no typecheck),
but it's a real type gap — the #614 count field is declared, this one wasn't.
Added the optional numeric field.
2. The general-settings update endpoint validated general_max_files_per_upload
but not general_max_file_size_mb, so an out-of-range value (0, -1, huge)
could persist. publicSettings then advertised the raw value while
getMaxFileSizeMb() normalised it — the guest UI would reject files the
backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB).
3. The update route cleared the file-count cache but not the new file-size
cache, so for up to 60s the public endpoint could advertise a new limit
while multer still enforced the old one. Now clears both under the same
uploadLimitTouched guard.
Follow-up on the merged #823 (main-only), so this targets main only.
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.
- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
null stores NULL (inherit) rather than being coerced to 0/false by
formatBoolean on SQLite. The update handler already did `=== null ? null`.
Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.
Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
Production installs use docker-compose.production.yml (the README's documented
path, pinned GHCR images, no dev services), but the dashboard's update
instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose`
operates on docker-compose.yml — a different, build-based stack — so a
production user who followed the steps:
- never pulled/recreated their real containers (stayed on the old version,
e.g. stuck on 3.44.0 after "updating" to 3.45.2), and
- started the dev-only mailhog service that docker-compose.yml defines
(reported restart-looping).
The backend runs inside a container and can't stat the host's compose files, but
docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env
and docker-compose.yml does not. detectEnvironment() now derives
isProductionCompose from it, and the Docker update steps prepend
`-f docker-compose.production.yml` when set. The non-production branch keeps the
bare commands but the warning now tells users to add `-f docker-compose.production.yml`
if they installed with it.
Also gates the mailhog service in docker-compose.yml behind a `dev` compose
profile so a plain `docker compose up -d` never starts it (opt in with
`docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes
from .env), so gating is safe. Verified: `docker compose config` lists mailhog
only with `--profile dev`; production compose is unchanged.
Adds unit tests for the production-vs-default command generation.
The admin's Settings → General → "Max File Size (MB)" value
(general_max_file_size_mb) never applied to guest gallery uploads — the guest
route hardcoded multer's per-file cap at 50MB (gallery.js) and the guest UI
hardcoded the same 50MB client-side guard and "max 50MB" hint text. So a guest
could not upload a large video even when the admin raised the limit (reported by
mat1990dj on #613). Same class as the file-count miss fixed in #614, for size.
- uploadSettings.js: new getMaxFileSizeMb()/getMaxFileSizeBytes() reading
general_max_file_size_mb (default 50MB, cached 60s, clamped to a 10GB ceiling),
mirroring getMaxFilesPerUpload.
- gallery.js (guest upload): multer limits.fileSize now resolves from the
setting; a LIMIT_FILE_SIZE error returns an actionable "max N MB" message.
- publicSettings.js: exposes general_max_file_size_mb (default 50) so the gallery
UI can render the real limit and guard client-side before an oversized POST.
- UserPhotoUpload.tsx: reads the limit, uses it for the client-side size guard,
and passes it to the requirements hint. The "max 50MB" literal in
upload.fileRequirements is now interpolated ({{sizeLimit}}) across all 8
locales; adds upload.fileTooLarge (en/de; others fall back to en).
Scope: guest path only (the reported gap). The admin path keeps its generous
10GB cap — admins are trusted and default 50MB would otherwise regress large
admin video uploads. Format and batch-size limits already work correctly and are
untouched. Adds SQLite-backed unit tests for the new getter.
Verified end-to-end on a booted instance: admin sets 500MB → persisted → public
settings exposes 500 → guest multer sources its cap from it.
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
- GET /api/events → every gallery's bcrypt password_hash, share_token, and
client name/email (the list handler selects * and mapEventForApi keeps
those columns),
- PUT /api/events/:id → reset any gallery's password (full takeover),
- DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.
Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).
Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
Implements the three restore-hardening items deferred from the #811 Codex
review (all validated against a real Postgres, see __tests__/integration/
picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport).
1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/
customer/event ids, so ANY pre-restore JWT can rebind to a different restored
principal. Revoking just the importing token wasn't enough. importFromPicpeak
now stamps a unix-second cutoff in app_settings after the restore commits, and
adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token
whose iat predates it (cached 30s → one in-memory compare on the hot path).
The operator's forced re-login mints a token past the cutoff, so it passes.
2. Role preservation across an RBAC replace (captureOperatorRole /
preserveOperatorRole). The operator's role + granted permission NAMES are
captured before the wipe; after roles/role_permissions are replaced the role
is resolved by NAME against the restored data, and re-created with its grants
if the backup omits it — so a crafted or cross-instance backup can't silently
downgrade or lock out the operator. reinjectCurrentAdmin now returns the
operator's id so the row can be re-pointed at the resolved role.
3. Postgres identity-sequence resync (resyncSequences). batchInsert writes
explicit ids without advancing the sequences, so the next natural insert into
any restored table collided on the PK. Runs AFTER commit (setval isn't
transactional) and guards every table with a column-existence check —
pg_get_serial_sequence RAISES on id-less tables like role_permissions.
No-op on SQLite.
Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres
integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence
resync, the id-less-table guard, explicit-id reinject, role re-creation, and a
full cross-instance replaceAllTables run asserting operator preservation, role
re-establishment, FK integrity, and collision-free post-restore inserts.
Stacks on #811 (shares the reinject hardening); merge after it.
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):
- MFA hijack: reinject wrote back only password_hash/is_active/
must_change_password, leaving a crafted backup's two_factor_* on the
operator's row — it could strip or replace their second factor. The email-
matched row is now updated with the operator's full AUTH set (login identity,
password, and all two_factor_* columns). Relationship/audit FKs (role_id,
created_by) are deliberately NOT forced from the snapshot: on a cross-instance
restore those pre-restore ids may be absent from the backup and would dangle
the FK (SQLite rolls back at commit); the restored row keeps its own valid
values.
- Cross-instance restore rollback / FK safety: reinject matched only by email,
so a backup shipping a different admin with the default `admin` username hit
UNIQUE(username) and rolled the whole restore back; email and username could
even collide on two different rows. Reconciliation is now non-destructive:
the email-matching row is updated in place (id preserved → restored FKs like
events.created_by stay valid); any different row holding the operator's
username is RENAMED, not deleted (deletion would fire ON DELETE actions /
dangle references); only when no row has the operator's email is a fresh row
inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
left the Postgres identity sequence unadvanced, so a sequence-based insert
could collide).
- Stale session after restore: admin_users ids shift on restore, but the
operator's live JWT is bound only to decoded.id (IP logged not enforced; the
backup controls password_changed_at). The route now revokes the token (result
checked and logged) and clears the admin cookie; the client redirects to a
fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
guarantee.
Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.
Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.
Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.
Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.
Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.
Adds route regression test covering the bypass, the public path, and bad tokens.
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.
Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
The frontend image kept shipping vulnerable OS packages (nginx 1.28.3-r1,
curl/libcurl 8.19.0, c-ares 1.34.6) despite the apk upgrade line, for two
independent reasons:
1. The runtime stage's apk upgrade layer was cached indefinitely — the
CACHEBUST build-arg CI passes (github.run_number) was only declared in
the builder stage, and ARGs don't cross stage boundaries. Both
Dockerfiles now redeclare CACHEBUST in the runtime stage and consume it
in the apk RUN, so every build re-runs the upgrade and picks up current
Alpine security updates.
2. nginx itself can never upgrade via apk on the nginx.org-based image:
the bundled nginx-module-* packages pin the exact nginx version, so
Alpine's patched 1.28.3-r4 is unreachable (verified empirically —
apk add --upgrade nginx is a silent no-op). nginx fixes must come via
the base tag, so bump to nginx:1.30-alpine (current stable, 1.30.4 on
Alpine 3.24, same nginx.org conf.d layout — drop-in).
Verified: local image build scans clean with Trivy (0 OS findings, was 21);
container serves /health, SPA fallback, and BRAND_TITLE envsubst as non-root
nginx user.
Closes code-scanning alerts 371-374, 376-392 (nginx HTTP/2 & module CVEs,
curl CVE-2026-5773/-6276 + 6 medium, c-ares CVE-2026-33630).
Two pre-existing bugs surfaced while reviewing #806 (kept separate per
scope policy — no OIDC code here):
- backup_s3_secret_key and backup_rsync_ssh_key (an SSH PRIVATE KEY)
were returned in PLAINTEXT by GET /admin/backup/config and by the
generic settings reads (GET /admin/settings and /admin/settings/:type
— which mask the recaptcha/umami/rybbit keys but not these). All
three now mask with the established bullet sentinel, and
PUT /admin/backup/config skips the sentinel on write so the edit form
round-trips without clobbering stored credentials (same pattern as
the email/WhatsApp config endpoints)
- /api/auth/admin/login/mfa was missing from the maintenance-mode
allowlist: the first login step passed, the second factor got a 503 —
any MFA-enrolled admin was locked out exactly while maintenance mode
was on
Regression tests: masking on all three read paths, sentinel round-trip
preserves stored values, real rotation still writes.
- OIDC-owned accounts can never authenticate locally: the password
login rejects auth_provider='oidc' rows outright (generic 401), and
the super-admin password reset refuses them with a clear message —
previously a reset would have minted a local password bypassing the
IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
AdminAuthContext hydrates user state from it: an SSO redirect
establishes the session without any login JSON, which left the header
identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
(same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
(leftJoin, prefixed columns, aliases) — the route change made the old
mock throw, which read as "table missing, trust token"
Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
guarantees sub uniqueness within an issuer, so a sub-only lookup let a
newly configured IdP's user inherit an old IdP's admin account on
subject collision; migration 162 gains external_issuer + composite
unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
email — spec-compliant providers may serve email/profile claims only
there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
generic settings reads (GET / and GET /:type)
Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
state cookie lives); final redirects absolute to the frontend base;
login button builds its URL via buildResourceUrl — split-origin
deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
blank issuer/client while enabled=true survives; enabling requires a
derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
secret rotation)
- email→admin linking claims the row atomically (conditional update on
external_subject IS NULL) — concurrent first-time callbacks with the
same verified email but different subjects can't both authenticate
Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
CI exposed that getFrontendBaseUrl() returns '' without FRONTEND_URL or
the general_site_url setting (local runs were masked by backend/.env):
the flow then sent a RELATIVE redirect_uri to the IdP, which surfaced
as an opaque IdP-side error. getRedirectUri now throws OIDC_BAD_CONFIG
with an actionable message (login route maps it to sso_error=config);
the settings GET degrades to an empty redirect_uri instead of 500ing.
The test pins FRONTEND_URL explicitly so it runs identically with and
without a local .env.
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.
Backend:
- migration 162: admin_users.auth_provider ('local' default) +
external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
cached discovery, sub-based identity binding — email linking of
existing admins only with email_verified=true; JIT behind
oidc_autoprovision with configurable default role and an unusable
random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
the callback reuses the local login's session establishment
(completeAdminLogin split into establishAdminSession + JSON wrapper)
so SSO sessions are identical downstream; every failure lands on
/admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
write-only, redacted to a set-flag; registered ABOVE the generic
/:type matcher which would shadow them); oidc_client_secret added to
the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
login page
Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
autoprovision + default role, button label, enable toggle, redirect
URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
param surfaced as translated toasts; EN+DE i18n
Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.
MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.
Render the row whenever either part has content and gate only the
category scroller on categories existing. The media-count label hides
below lg when no categories exist so the mobile layout stays unchanged
(mobile keeps its own chip block). With-categories galleries render
identically to before.
Regression test pins both chip groups in the DOM with and without
categories (fails on the pre-fix component).
Split out of #801 so the public-API behavior change gets its own review:
- v1 POST /events validates event_type against the live event_types
catalog instead of the hardcoded whitelist — custom types created in
Settings → Event Types were rejected with 400. BREAKING for the
never-seeded 'family' slug, which the old whitelist silently accepted
and wrote as a dangling reference; create a matching event type to
keep using it
- new GET /api/v1/event-types (read scope) so API-token clients can
discover valid slugs; OpenAPI enum replaced accordingly
- standalone contract→event conversion no longer hardcodes
event_type: 'wedding' — it resolves via crm_default_event_type, then
the catalog catch-all, same chain as quote→event conversion
- resolveDefaultEventType moved from quoteService to eventTypeService
for shared use (no behavior change)
Keeps #801 scoped to the setup-wizard event-types feature and its
load-bearing guards. The v1 validator/discovery endpoint and the
contract-conversion default fix ship separately so the public-API
behavior change gets its own review weight.
Three review rounds on PR #801; fixes in response:
- isValidEventType: live catalog is authoritative when it has rows — a
deleted or deactivated slug no longer validates via the legacy
fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
updateEventType: refuse deactivating the last active type (unknown
slugs are rejected since the validator change, so an empty active
catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
(a portable-backup restore can leave the key absent) and a normal
admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
stripped from ALL generic settings upserts (/general, /security,
/analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
and a genuinely failed system-type deletion reloads the list and
stays on the step instead of advancing past the only window in which
it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
discover valid slugs; OpenAPI enum replaced with the live-catalog
description
The catalog-backed event_type validator (#800) makes a db('event_types')
lookup before the handler runs, which consumed the first queued mock
chain and shifted the pinned db() call sequence — 5 tests failed on CI.
Stub isValidEventType to true (validation isn't this suite's subject)
and add an explicit test for the new 400-on-unknown-type path.
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.
- New wizard step between features and config: edit name/URL prefix,
remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
an admin already exists, false on fresh installs; POST /api/setup/
complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
in-use check extended to quotes; per-type reminder template
(event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
removed from the catalog
- v1 API event creation validates event_type against the live catalog
instead of a hardcoded whitelist (custom types were rejected; the
never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
crm_default_event_type / resolveDefaultEventType instead of
hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
to eventTypeService for reuse)
Two invoice-PDF changes from #794.
1. VAT / free-text note (Benedikt's request, placement A). A new
`crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
free-text line directly under the MwSt. row on every invoice. Data-driven:
the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
totals-block reserve grows by the measured note height so a long note can't
push the grand total into the footer. Read in invoice/render.js, threaded
through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
unaffected.
2. Multi-page footer overlap. On a full continuation page the line-item table
filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
marginBottom-12 — INSIDE that fill zone — so items overlapped the page
number. Move the stamp into the bottom margin (below the content edge),
zeroing that page's bottom margin during the write so it can't trigger
PDFKit's auto-page-break. Verified: on a full page the lowest item text is
at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.
Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
inline check (super_admin bypasses; others limited to owned/ownerless events).
New test covers a settings.edit-holding non-super_admin blocked (403) on all
three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.
Order a gallery's categories in the flow of the day instead of A–Z. Two layers,
resolved per event: per-event override > global default > name.
- migration 158: photo_categories.display_order (global default), backfilled
from the current alphabetical order so existing galleries don't reshuffle.
- migration 159: event_category_order (event_id, category_id, position) — the
per-event override; no backfill, every event starts on the default.
- utils/categoryOrder: shared resolution used by the admin event view and the
public gallery; fails safe to the global default if the table is absent.
- adminCategories: POST /reorder sets a per-event override (globals +
event-specific, interleaved); DELETE /reorder/:eventId resets; POST
/reorder-global sets the global default. Ordering endpoints + create append.
- gallery renders the resolved order.
- Settings → Photo Categories reorders the global default; an event's Categories
tab reorders that gallery (one combined list + Reset to default). Up/down
buttons — no drag-and-drop dependency.
- en/de strings.
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).
Replace the v{{version}}/v{{major}} semver patterns with type=ref,event=tag,
which emits the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) for both
stable and beta tags — exactly the string users pin (matches the GitHub
release). Applies to both backend + frontend merge metadata steps.
Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:
- **Play order** (show_order): 'chronological' (upload order, default) or
'random' — the client shuffles the initial set (Fisher-Yates) so
live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
single photo category (NULL = all photos, default). Enforced
server-side on the slideshow /photos access and mirrored in the
/session + /state photo_count, so the kiosk viewer can't widen the set.
Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).
- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
(picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
photos + matching count; order propagates) and the running kiosk
requests exactly the filtered set; tsc clean, 136 backend tests pass.
Add picpeak/backend + picpeak/frontend on Docker Hub alongside GHCR. The
merge jobs already assemble the multi-arch manifest from the per-arch GHCR
digests via 'imagetools create'; adding Docker Hub to metadata-action's
images list + a Docker Hub login makes the same command push the manifest to
both registries (blobs copied from GHCR). No change to the build-by-digest
jobs.
Full tag parity (main, stable, latest, semver, sha). Gated on
DOCKERHUB_ENABLED (github.repository == PicPeak/picpeak) so forks stay
GHCR-only and keep building. Requires repo secrets DOCKERHUB_USERNAME and
DOCKERHUB_TOKEN.
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.
Also records the release-engineering note that release-please.yml must
keep target-branch: stable (the missing pin cut a bogus v2.7.0 once).
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.
- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
(githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
(`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
docker/metadata-action's type=semver strips the leading 'v', so releases
published only :3.45.0 / :3.83.1-beta.0. But git tags + GitHub releases
are named v3.45.0, so anyone pinning ghcr.io/.../backend:v3.45.0 (the
obvious choice) hit 'manifest unknown' — exactly #664.
Add v-prefixed semver patterns (v{{version}}, v{{major}}.{{minor}},
v{{major}}) alongside the existing bare ones, for both backend and
frontend. Now both :v3.45.0 and :3.45.0 resolve.
Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.
- release-please.yml: add `target-branch: stable` so it releases from
the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
package.json), set `.release-please-manifest.json` to 3.44.0, and drop
the bogus 2.7.0 CHANGELOG section.
The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
- Search now hits the backend (debounced) so results aren't truncated to the
first loaded page: /received gains a `q` filter (sender/subject); the frontend
passes the debounced term to every list query. The instant client-side filter
stays for responsiveness.
- Reply/compose recipient extracts the bare address from a "Name <addr>" From
header (extractEmail) — also used for the customer-lookup key.
- Added the full de + en `messages.*` and `email.customerMailbox.*` translation
namespaces (were English inline-fallbacks only). Swiss-German spelling.
- BLOCKER: stored XSS via inbound sender display name. The reply stub built raw
HTML with the unsanitized From name and set it as innerHTML on the composer's
contentEditable (admin origin) → onerror JS ran on Reply. Now HTML-escape
from_address in the stub AND DOMPurify-sanitize the composer body before
innerHTML (defense in depth).
- Gate the NEW Messages routes with requireFeatureFlag('messaging') per-route
(queue/:id, received/:id, item/*, identities, accounts, accounts/test, send)
— NOT the shared /email mount, so the pre-existing email-config endpoints stay
ungated.
- DocumentActionModal auto-picks a customer only on an EXACT email match
(customer search is prefix/fuzzy), else leaves the picker to the admin.
- Search box in the header filters the current folder's list (sender/subject),
client-side; works across the merged Archived/Deleted views too.
- Archive and Delete are now implemented as soft moves: migration 157 adds
mailbox_state ('active'|'archived'|'deleted') to email_queue + received_emails.
Archive → 'archived', Delete → 'deleted' (trash). Restore → 'active'. Deleting
FROM the Deleted folder is permanent (hard row delete).
- New cross-account system folders Archived + Deleted (merge sent + received of
that state, sorted by date). Normal folders now exclude archived/deleted.
- Backend: /queue + /received gain a `state` filter (default active + legacy
NULL); new POST /item/:kind/:id/state (archive/delete/restore) and DELETE
/item/:kind/:id (purge, email.edit).
- Toolbar Archive/Delete wired; Restore + "Delete permanently" shown in the
system folders.
Frontend build + migration boot (157) verified.
Pre-upstream review hardening:
- /accounts + /accounts/test now reject private/internal IMAP/SMTP hosts via
isPrivateIP(), matching /config + /incoming-config (SSRF).
- QueueDetail body iframe uses sandbox="" (script-less, no same-origin) like the
inbound pane, instead of allow-same-origin.
- /send sanitizer drops the <style> tag + data: scheme to match the stricter
inbound sanitizeBody allowlist.
- Per-account SMTP transport sets tls.rejectUnauthorized explicitly.
CustomerPicker uses its 'label' prop as the selected-customer chip text, so
passing the static 'Customer' string hid the actual name. Pass the resolved
customer's name as label and add a separate field heading.
The toolbar doc buttons now open a real document-action flow instead of just
loading an email template:
- DocumentActionModal resolves the customer from the message's sender address
(customers/search); if no match, the CustomerPicker lets you search or create
a passive customer inline.
- Create new -> jumps to the real editor prefilled with the customer
(quotes/contracts/bills ?customerAccountId=), so numbering, line items and PDF
all come from the existing CRM. Gallery opens the event editor.
- Select existing -> lists that customer's quotes/contracts/invoices and drops
the chosen document number into a reply composer.
- Toolbar buttons are gated by the global feature flags (quotes/contracts/bills).
Adds the missing customerAccountId prefill to ContractEditorPage (quotes + bills
already had it). Frontend-only; reuses existing endpoints. Build verified.
Addresses dev-test feedback:
- Sidebar + reading-pane addresses (rechnungen@ / hello@ / no-reply@) are now
read from the mail config via GET /admin/email/identities, not hardcoded.
- Highlight/selection now uses the branding accent (bg-accent-soft /
text-on-accent-soft / accent-dark) instead of hardcoded blue, so it follows
the admin's CI colour like the sidebar.
- Header gains "New message" (compose) and "Sync" (poll mailboxes now) buttons.
- Composer modal enlarged (920px, taller editable body).
- Customer mailbox (hello@) now has BOTH incoming (IMAP) and outgoing (SMTP)
settings — migration 156 adds smtp_* + from_* to mail_accounts;
emailProcessor.sendRawEmail takes an accountKey and sends via that mailbox's
SMTP identity (falls back to the global from). Manual/reply sends from the
Messages UI use the 'customers' identity, so replies come from hello@.
Frontend build + migration boot (156) verified.
The Messaging FeatureCard was a hardcoded-disabled 'roadmap' placeholder
(no-op toggle), so the messaging flag could never be turned on — the Messages
sidebar item + page stayed hidden. Wire the toggle to setFlag, mark it 'new',
and describe the actual admin Messages client.
The CRM action buttons and Reply now open a send-composer, not a silent
templated send.
- New send-composer (MessageComposer): loads the rendered template (via
previewTemplate) or a reply stub into a fully-editable body — the admin can
rewrite it or drop a note anywhere before sending. On send it goes out as-is
(server-sanitized), no template re-render.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
configured SMTP identity; POST /admin/email/send sanitizes + sends + records
the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
the human/edited messages (which finally populates that folder). /queue gains
an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
Create Quote/Contract/Invoice open the composer with that template loaded;
Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
(later phases). After send, jumps to Customers ▸ Sent.
Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
Address review on #764: backfillDunningRuns emitted invoice.sent without a
target, so enabling dunning would also enroll every historical open invoice
into any custom invoice.sent flow. Pass the enabled flow's id through to
emitWorkflowEvent so the backfill only touches dunning. Also note the
computeWakeAt both-fields (untilVar + delay) behaviour change in its comment.
Second inbound mailbox and real message bodies for the Messages viewer.
Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
accounting keeps its exact attachment->expenses behavior, customer mail is
logged with its body and NOT routed to accounting. Inbound HTML is sanitized
server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
(bodies excluded from the list); new GET /received/:id returns the body;
GET/POST /accounts + /accounts/test manage the extra mailboxes.
Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
configure + test the hello@ IMAP box.
No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
Repro: create an event, click into the date field, backspace a day digit.
The whole page white-screened and needed a reload.
Root cause: LocalizedDateInput's `toIso` only checked the day/month were
1-2 digits, not that they formed a real date — so a mid-backspace value
like "0/07/2026" was coerced to the string "2026-07-00" and committed to
`event_date`. CreateEventPage then rendered
`format(addDays(new Date('2026-07-00'), days))`, and date-fns `format`
throws RangeError on an Invalid Date — thrown during render, so React
tore the tree down to the error boundary.
Two complementary fixes:
- `toIso` round-trips the parsed y/m/d through `Date` and rejects
impossible dates (day 00, month 13, 31 Feb…), so the field never
commits a value that isn't a real calendar date.
- `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid`
and return '' instead of throwing — defence in depth for the ~57 call
sites that could otherwise white-screen on a bad date.
Verified live: backspacing to a partial/invalid date no longer crashes
(the form stays rendered), a valid date still commits + the expiry
preview renders. Adds a LocalizedDateInput regression test; tsc + build
green.
New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):
- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
/ Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
received_emails (listReceived). Customers folders show an explanatory empty
state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
Book-as-expense-Re-bill) present but disabled — wired in later phases.
Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
Addresses the PR #763 review: the invoice_sent / storno_issued / payment-check
/ paid-admin-notification emails share the identical event-first language bug
and never set __language, so a German customer on an English-gallery event got
an English email body with German-formatted amounts.
Each call site already computes the locale it formats amounts in, so this is a
one-liner per call — the body language now matches the amount formatting:
- invoice_sent, storno_issued (sending.js) -> __language: ctx.locale
- payment-check, invoice_paid_admin_notification (payments.js) -> __language: locale
Leak-safe (no template references {{__language}}) and falls back to the
existing event-first resolution when unset, per the mechanism added in #763.
Enabling the invoice-dunning built-in suppressed the legacy reminder ladder
but only created runs for invoices sent AFTER enabling — already-sent unpaid
invoices got dunned by neither. Now:
- Turning dunning ON enrolls every open sent/overdue unpaid invoice via
emitWorkflowEvent('invoice.sent') (engine.backfillDunningRuns(), wired into
the workflow enable toggle). Idempotent via the per-(flow,entity) dedup.
- The grace wait is anchored to the invoice's due date: computeWakeAt now
treats { untilVar, delayDays } as "var + offset" (was var-only OR now+offset),
and the built-in's waitGrace becomes { untilVar: 'dueDate', delayDays:
firstDays } (seed v6 -> v7). An already-overdue invoice duns on its real
timeline instead of restarting a fresh grace clock.
Note: the due-date-anchored graph applies to freshly seeded built-ins; an
already-admin-enabled dunning workflow still enrolls via backfill but keeps its
current grace timing until re-seeded.
- Billing/dunning emails no longer render in the gallery event's language.
emailProcessor now honors an explicit `__language` in the email data
(else falls back to the event-first recipient resolution), and the invoice
reminder passes the customer/invoice locale (customer.preferred_language
|| invoice.language || 'de'). Fixes German customers getting English
dunning notices. (#760)
- Payment-check confirmation card ("Action recorded") is now theme-adaptive
(green tint + readable text on both light and dark surfaces) instead of a
hardcoded light-green mix + dark-green title that vanished in dark mode. (#759)
- The per-customer "Preferred language" field already exists
(CustomerDetailPage) plus the business-profile default; updated the helper
text to note billing emails now honor it too. (#761)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.
Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.
- Migration 153: hero_logo_size nullable + backfill NULL so existing
galleries inherit the global size (restores GalleryLayout's prior
live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.
Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).
Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.
- Migration 152: make events.hero_logo_visible nullable and NULL out the
defaulted rows so existing galleries follow the global going
forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
(GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
Always show / Always hide (en + de).
Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens
to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync):
Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal,
Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap,
Google-PageRenderer, OdklBot, ZoomBot.
Deliberately NOT added: UAs shared with real human in-app browsers
(WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings
('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG
response is meta-only with no redirect, so matching those would serve a
human the bare stub. New negative test locks that exclusion in.
Verified: nginx -t passes; live harness confirms the new tokens rewrite
to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15.
Follow-up to #699/#700/#702 — the OG SSR handler existed but three link
shapes never reached it behind the frontend nginx:
- Branded short URLs (/s/<slug>, #702) had NO nginx location, so they fell
through to the SPA — which has no /s/ route. Dead for humans (no 302
redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose
/s/:shortSlug route already handles both.
- Slideshow links (/gallery/<slug>/show/<token>) have TWO extra path
segments; the crawler-detect location regex allowed only one, so they
never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra
segments (quoted regex — the braces would otherwise be parsed as nginx
config delimiters). client-access still matches (its token is in ?query,
one path segment).
- Viber's preview fetcher wasn't in either UA list, so Viber shares showed
no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync).
Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness
confirms /s/ proxies to the backend, slideshow + Viber + share-token +
client-access crawler UAs all rewrite to /og/gallery/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
queuePaymentCheckEmail queued the admin payment-check email with template
key 'invoice_payment_check_admin', but no such template exists — the only
one is 'invoice_payment_check' (crmEmailTemplates.js:217, seeded by
migration 116), which IS the admin "Paid / Partial / Not paid" email. The
processor does an exact template_key lookup and throws "template not
found", so every dunning admin payment-check email failed, retried to the
cap, and got stuck pending.
One-word fix: queue 'invoice_payment_check'. Unbreaks the built-in
invoice-dunning flow's email step. (Rebased onto the post-decompose
invoiceService refactor — the line now lives in invoice/payments.js.)
#730 — the account step's Create-admin button shared a flex row with Back;
its label + loading spinner exceeded the card width, so the button
overflowed the card outline while submitting (and was fragile for longer
i18n labels). Stack both buttons full-width — the primary always has room
for the spinner now, matching every other wizard step.
#732 — add a final 'community' step, shown once on first-run after
config / no-config, before entering the app. Mission line + four link
cards (report a bug, request a feature, star/share, Buy Me a Coffee),
all target=_blank rel=noopener, and a Finish → Dashboard button. Fully
i18n (en + de). Restore keeps its reload flow (a restored instance is no
longer first-run, so it never reaches this step). Adds .github/FUNDING.yml
so GitHub renders a Sponsor button too.
Verified live: drove the real first-run wizard end to end — stacked
account buttons render inside the card, community step shows the mission
+ all four links, Finish lands on the dashboard.
Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
per-user setup (QR + manual secret + verify), recovery codes shown once
(copy/download/confirm), status, regenerate, disable. Renders for
super_admin (closes#735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
code step (TOTP or recovery), call /auth/admin/login/mfa; handle
MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
(and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.
Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
Backend for #738. Real TOTP 2FA for admin accounts, all roles incl.
super_admin (closes#735).
- mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest
(key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed,
single-use recovery codes; otpauth URI + QR.
- Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at
(secret/enabled columns already existed from legacy 016).
- Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status,
POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate
require a current code so a hijacked session can't strip 2FA.
- Login challenge: /admin/login returns {mfaRequired, mfaToken} (no
session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery
code for the session. Lockout counter is NOT reset until the second
factor passes, so MFA brute-force is rate-limited too.
- CLI break-glass: scripts/reset-admin-mfa.js --email <e> | --all --yes,
audit-logged, matches reset-admin-password.js convention.
- Docs + optional MFA_ENCRYPTION_KEY env.
Verified end-to-end on a live backend: enroll (super_admin), challenge,
TOTP + single-use recovery login, disable, and CLI reset.
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.
HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
flat /thumbnails/thumb_* file, so a visitor to one gallery could
enumerate another (password-protected) gallery's entire thumbnail set.
Scope thumbnail access to the token's event via photos.thumbnail_path.
Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
event ids with no owner filter (single-event routes enforce
requireEventOwnership), letting admin/editor archive or cascade-delete
any event. Add filterOwnedEventIds; also guard rename + import-external;
tighten photo-retry to scope admin (not just editor). Fix misleading
bulk-delete comment.
MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
(#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
to absolute/external URLs — only attach to relative same-app paths.
LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.
Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
Same two pre-existing-on-main bugs, at their post-decomposition
locations: clampIntOrUndefined in adminEvents/crud.js slideshow seed;
!! coercion in EventDetailsHeader, EventInformationCard,
ClientAccessCard. Keeps this branch correct in either merge order with
#734 — when merging main afterwards, resolve the adminEvents.js
modify/delete conflict by keeping the deletion.
On SQLite deployments boolean event columns come back as 0/1, and
{event.is_draft && ...} renders the 0 as a literal text node. Visible on
the event details page in three spots: above the tab bar (is_draft),
in the download-protection badge row (disable_right_click /
enable_devtools_protection / watermark_downloads), and in the Client
Access card (client_access_enabled). Coerce with !! at the render sites.
The create route seeds show_interval_ms/show_transition_ms from
app_settings through an inline guard that pre-checked Number.isFinite(+v)
but then used parseInt(v). The two disagree for null/''/true — +null is 0
(finite) while parseInt(null) is NaN — so when the slideshow settings rows
are absent (getAppSetting returns its null default), NaN flowed through
Math.min/Math.max into the INSERT. PostgreSQL rejects NaN for integer
columns; SQLite silently stores NULL, which is why every SQLite-based
test passed while POST /api/admin/events 500'd on the PG dev stack and
broke the e2e smoke suite.
Fix: parse first, then check — clampIntOrUndefined in utils/numericHelpers
(unit-tested against every failure-mode input). Verified end-to-end: the
previously-failing minimal create now succeeds against the PG dev stack.
- eslint --fix on branch-changed backend files (indent shift from the
module-wrapper nesting in decomposed files); backend lint now 904
errors vs 1,315 on main
- useMutationWithToast forwards all four TanStack v5 callback args
(tsc -b strict build flagged the 3-arg passthrough)
- 92 mutations across 40 files moved to useMutationWithToast
(success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
to avoid introducing new error toasts
26 tests as a safety net ahead of decomposition — invoice create/list/
status transitions, adminEvents CRUD via Supertest+SQLite, backup config
parsing and manifest validation.
From the-luap's review:
- Import no longer trusts manifest.tables blindly. It now intersects the
manifest's table list with the real data tables of THIS database
(listDataTables(), which already excludes knex_migrations/_lock) and
drops anything else. A crafted/corrupted .picpeak listing knex_migrations
or a non-existent table can no longer wipe it; skipped tables are logged.
- The Postgres session_replication_role='replica' SET (needs superuser) is
now wrapped: on a managed-PG non-superuser it fails BEFORE any rows are
deleted (transaction rolls back) and surfaces a clear, actionable 400
instead of a cryptic permission error.
- Export: on an archiver error, the temp out dir (a partial plaintext-secret
archive) is now removed instead of orphaned.
Tests (+4, now 26): engine-mismatch rejection, forward-only newer-refused,
non-picpeak rejection, and files/ restored + filesRestored asserted.
When the chosen features need config the wizard can collect, 'Finish' on
the usage step now advances to a lean config step instead of jumping to
the dashboard:
- Invoicing (if Invoices): company/legal name, address, VAT-ID or tax
number, IBAN, currency → saved to business-profile + a default bank
account. Carries the bank/VAT legal disclaimer.
- Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/
user/pass/from → saved to email_configs.
Each section persists only if started, and 'Skip for now' is always
available — soft settings keep their seeded defaults. en + de strings.
The usage step now offers 'Migrating from another PicPeak?' → a restore
step that uploads a .picpeak (reusing PicpeakRestoreCard) to clone another
instance onto this fresh one, preserving the account just created. en + de
strings added.
Removes the redundant standalone .picpeak card. The wizard's 'Upload
Backup' source now splits into two kinds: '.picpeak backup' (the working
portable restore — renders the upload + destructive-confirm flow inline)
and 'Manifest + files' (legacy, still 'Manifest Upload functionality
coming soon'). en + de strings added.
The setup page background used var(--color-background), which flips to
#0a0a0a under the .dark class while the wizard card stays hardcoded light
— giving a dark page + light card mismatch in dark mode. Pin the first-run
screen to its intended light branded look (fixed #fafafa bg / #171717 text)
so all three steps render consistently.
Downloading a portable backup is a "make a backup" action, so it belongs
next to "Run Backup Now" on the Dashboard, not under Restore. Split the
combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard
(Restore). The manifest stays bundled inside the .picpeak, so there is no
separate manifest-only download for the portable format.
Two Postgres-only bugs found by a live docker-pg roundtrip (SQLite tests
passed because neither reproduces on SQLite):
- Export: knex `.stream()` pulls in the optional `pg-query-stream` module
(not bundled) and throws on pg. Switched to a plain per-table `select`
— works on both engines, no new dependency. Rows are DB metadata
(blobs live under files/), so holding a table in memory is fine.
- Import: the pg driver returns json/jsonb columns as parsed JS values,
so re-inserting a scalar like the string "PicPeak" sent it unquoted and
pg rejected it ("invalid input syntax for type json"). Now introspects
each table's json/jsonb columns and re-serialises those values before
insert (pg only; SQLite stores json as TEXT and round-trips as-is).
Verified end-to-end on docker Postgres: export 85 tables, full-override
import, current account preserved, post-backup data removed.
Adds a self-contained "Portable backup (.picpeak)" card to the Restore
tab, completing the GUI-only roundtrip:
- Download: optional "include original photos" toggle + a prominent
plaintext-secrets warning, streams the file via a blob download.
- Restore: file picker → destructive confirmation modal ("replaces ALL
data except your current account, cannot be undone") → multipart upload
to /admin/backup/picpeak/import → success summary. If the backup uses
external media, shows a banner to reconfigure the mount, with a docs link.
Kept separate from the legacy RestoreWizard (different format/flow). en+de
strings added; dark-mode variants throughout.
POST /admin/backup/picpeak/import — multipart upload of a .picpeak,
streamed to a temp file (after auth, so unauthenticated requests can't
push a large file to disk), then restored via picpeakImportService with
currentAdminId = the logged-in operator (preserved across the override).
Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to
reconfigure the external-media mount. Temp upload is always unlinked.
Completes the backend half of the GUI-only roundtrip (export download +
import upload). Multipart is already allowed by the CSRF content-type guard.
Receiving half of the roundtrip. picpeakImportService.importFromPicpeak():
- Validates the manifest: rejects non-picpeak files, a newer format, an
engine mismatch (pg↔pg / sqlite↔sqlite only), and a backup from a NEWER
schema than this instance (forward-only). knex_migrations absence is
tolerated (test harnesses).
- Snapshots the current logged-in admin, then wipes + reloads every table
from the backup NDJSON in one transaction with FK enforcement suspended
(pg: session_replication_role=replica reset before commit; sqlite:
defer_foreign_keys). knex_migrations is never touched, so the target's
schema/migration state is preserved.
- Re-injects the current account so the operator is never locked out; a
backup admin colliding on email is overwritten with the current creds.
- Restores files/ into storage and detects external-media references so the
caller can prompt to reconfigure the mount.
Roundtrip integration test proves: backup data restored, current account
survives a full override (different email → added), and the email-collision
case keeps the operator's password.
After the admin account is created (and we're logged in), the wizard now
shows an opt-in feature step instead of jumping straight to the dashboard.
Grouped ticks (Client management / Accounting / Automation) map to the
existing feature flags; galleries/analytics/userManagement stay always-on
and are noted, not listed.
- Selection is saved via the existing authenticated PUT /admin/feature-flags,
whose server-side applyDependencyRules resolves dependencies (e.g. Invoices
pulls in Accounting) — the wizard only sends raw ticks.
- Labels/descriptions reuse settings.features.<key>.title/description so
translations stay in sync (en + de verified for all 14 features).
- Saving is best-effort: on failure the admin still enters the app and can
set features later in Settings.
- New en/de strings for the usage step.
Option A (lean wizard): this is the feature-selection foundation; per-feature
hard-required config steps + the restore-from-backup branch come next.
First half of the GUI-only backup roundtrip. Adds a self-describing
".picpeak" archive that can be downloaded from one instance and (later)
re-uploaded to another via the web UI only.
- picpeakExportService.createPicpeak(): dumps every table as NDJSON
(tables introspected at runtime — no hardcoded list, won't rot), plus
a manifest (format version, app version, DB engine, latest migration,
per-table row counts + checksums, includePhotos, contains_secrets),
plus files/ (business-docs + uploads always; original gallery photos
only when includePhotos). NDJSON is engine-neutral so the target
rebuilds schema via migrations then loads rows — enabling pg↔pg /
sqlite↔sqlite and forward-only auto-migrate.
- GET /admin/backup/picpeak/export?includePhotos= streams the file and
sets X-Picpeak-Contains-Secrets (the file holds plaintext SMTP pass,
admin hashes, API keys — the UI must warn).
- Purely additive: no existing backup/restore path is touched.
Integration test proves the archive shape, knex-table exclusion, and
row-count/NDJSON consistency (85 tables on the seed schema).
SettingsPage.tsx imported `Mail` from lucide-react twice — in the main
icon block (line 20) and again in a later import (line 58). The
@vitejs/plugin-react babel transform rejects the duplicate with
"Identifier 'Mail' has already been declared", so `npm run dev` crashed
when the module loaded. The production `vite build` (esbuild) silently
dedupes it, which is why CI/Docker builds passed and it went unnoticed.
The two imports overlap only on `Mail`; drop it from line 58, keeping
that line's six unique icons (Briefcase, Receipt, ScrollText, Landmark,
Smartphone, MonitorPlay). Verified: single Mail import remains, prod
build passes, and the vite dev transform of SettingsPage now returns 200
with no "already been declared" error.
Closes Trivy alerts #375 (sigstore CVE-2026-48815), #321 (@sigstore/core),
#314 (tar) — npm@10 bundles the vulnerable sigstore 3.1.0; npm 11 ships the
patched 4.x. Safe because this npm is CLI-only in the final image: runtime
deps come from the builder stage's node_modules and the entrypoint runs node,
not npm, so the install-behaviour issues that motivated the 10.x pin never run
here. npm 11 requires Node >=22.9 — satisfied by node:22-alpine.
Trivy flagged nginx 1.28.3-r1 in the frontend image (alerts #371-374):
- CVE-2026-42055 (HIGH) HTTP/2 heap overflow
- CVE-2026-49975 (HIGH) HTTP/2 DoS
- CVE-2026-9256 (HIGH) rewrite_module code exec / DoS
- CVE-2026-48142 (MED) charset_module memory disclosure
All fixed in nginx 1.28.3-r4. The Dockerfile already ran 'apk upgrade
--no-cache', but the pushed image predated the fixed package and the layer
was cached on r1. Add an explicit nginx upgrade to force the layer to rebuild
against the current Alpine repos (which now carry r4).
Restructure picpeak-setup.sh around two clear modes:
- Interactive wizard (run_wizard): asks method → install dir → channel →
domain → HTTPS handling → admin email → SMTP, then shows a review and
confirms before installing. Each value already passed as a flag is
respected and its question skipped.
- Unattended (--unattended + flags): validate_unattended fills defaults and
fails fast on impossible combos (e.g. --enable-ssl without --domain).
New flags: --admin-password, --install-dir, --channel.
Align the Docker path with the rest of the project:
- Use the committed docker-compose.production.yml (prebuilt GHCR images) via
COMPOSE_FILE in .env instead of hand-generating a divergent compose file.
- Drop the broken setup_ssl_docker call (was referenced but never defined).
- Update path pulls images instead of building.
Admin bootstrap follows the browser-first model (#714): by default no
password is written; the one-time /setup token is surfaced (from
data/SETUP_TOKEN or the logs) with browser instructions. --admin-password
keeps the legacy seeded-admin + ADMIN_CREDENTIALS.txt flow for headless runs.
Depends on #714 (setup-token backend + secrets-init in production compose)
for the browser-first + zero-secret behavior at runtime.
Auto-merge enabled via GITHUB_TOKEN attributes the eventual merge commit to
github-actions[bot], so recursion prevention suppresses the resulting push to
main — the follow-up release-please run that cuts the tag/release never fires.
Net: the version PR merges but no release/tag/images are ever produced (#719).
Enable auto-merge with RELEASE_PLEASE_TOKEN instead (a real identity) so the
merge triggers the tag-cutting run. Approval stays on GITHUB_TOKEN because it
must be a different identity than the PR author (the PAT) to count as a review.
Observed on #723: merged 3.77.3-beta.0 but no run followed and no tag was cut.
The auto-merge step runs in a job with no actions/checkout, so gh could not
infer the repository from a git remote and failed with 'not a git repository'
(#719 follow-up). Set GH_REPO=${{ github.repository }} so gh pr list/review/
merge work without a checkout — same fix as the whatsnew workflow (2a5f0a8).
Confirmed working otherwise: with RELEASE_PLEASE_TOKEN set, release PR #721 is
now PAT-authored and its required checks run automatically (no manual approval).
Previously "Continue" on the token step only checked the field was
non-empty; a wrong token wasn't caught until the final submit, after the
user had filled in email + password. Add a non-burning verify:
- backend: POST /setup/verify-token constant-time compares the token
without consuming it (createInitialAdmin still claims it atomically on
submit), gated on no-admin-exists and rate-limited like /setup/admin.
- frontend: step-1 "Continue" calls verifyToken and only advances on a
valid token; a wrong token shows the invalidToken error on the field,
429 -> too-many-attempts, 409 -> redirect to login.
Adds integration tests for accept-without-burn / reject / closed-once-set.
The header logo used a hardcoded 64px frame; the login page renders a
medium (200x150) frame via resolveLoginLogoClasses. Reuse that helper
with the default size so /setup and /admin/login read identically.
The release PR (authored by github-actions[bot] via GITHUB_TOKEN) sat open
forever: its workflows were held behind 'awaiting approval' and the required
review could not be satisfied by the bot. Both release-please workflows now:
- Use a dedicated ${{ secrets.RELEASE_PLEASE_TOKEN }} (fine-grained PAT) with a
GITHUB_TOKEN fallback. A PAT-authored PR runs CI automatically (no 'awaiting
approval') and can be merged without a human.
- Auto-approve (as github-actions[bot], a different identity than the PR
author) and enable auto-merge on the open release PR, so it publishes once
checks pass. Skipped when no PAT is configured — falls back to today's manual
flow, nothing breaks.
A PAT also un-suppresses the tag-push and release-published triggers on
docker-build (GITHUB_TOKEN suppressed them), so add a concurrency group there
to collapse the duplicate same-version builds into one.
Requires (repo/org settings, one-time):
- Create fine-grained PAT RELEASE_PLEASE_TOKEN (contents:write, pull-requests:write).
- Enable 'Allow auto-merge' on the repo (currently off).
- 'Allow GitHub Actions to approve pull requests' — already enabled.
Address post-merge UI feedback on the first-run setup screen — the first
screen any new admin sees:
- Use the bundled PicPeak logo (same asset the login page falls back to)
on the cream brand plate instead of the generic lucide Sparkles icon.
- Split the flow into two steps: step 1 takes only the one-time setup
token, with the `docker compose logs backend | grep -i "setup token"`
recovery command shown prominently (with a copy button) directly under
the field, plus a docs link for when the logs have rotated away; step 2
collects email + password. A rejected token bounces back to step 1.
en/de strings added; other locales fall back to en.
Blockers:
- SetupPage now mirrors the server password rule (>=8 with upper/lower/digit) so
a green client isn't bounced by the server; server errors carry a `field`
(routes/setup.js) that the client maps to a translated key instead of
rendering raw English. New i18n: setup.invalidToken, setup.passwordRequirements.
- picpeak-setup.sh: the ADMIN_CREDENTIALS.txt block no longer dead-ends on the
wizard path — when no legacy admin was seeded it prints the one-time setup
token (from data/SETUP_TOKEN / docker compose logs) and points at /setup.
Concern:
- createInitialAdmin creates the admin + burns the token in ONE transaction,
atomically claiming the token (null-if-present, expect 1 row) so a
double-submit can't create two super_admins. Cross-DB (whereNotNull, trx-only
writes). Added a concurrency test.
Nits:
- SetupPage redirects to /login when /setup/status errors (no form flash on a
configured instance).
- Dropped the unused DATABASE_URL from docker-compose.yml.
- Documented why secrets are chmod 644 (three different reader users).
Any PR that changes a user-facing surface must include a screenshot of the
result in the description (before/after where it helps). Reviewers ask for
one before reviewing UI-touching PRs; backend/non-visual changes are exempt.
Records two features that merged with gitmoji commit subjects and were
therefore skipped by Release Please, so the next beta credits them:
- #707 grid/list layout toggle on the admin Photos tab
- #708 per-file upload failure report in the upload modal
No code change — the features are already on main; this commit only gives
Release Please a Conventional Commit to cut the release from.
Release Please only recognizes Conventional Commit prefixes (feat:, fix:,
...). PRs merged with other conventions (gitmoji, free-form) are silently
skipped, shipping changes with no version bump or changelog entry (see
#707/#708). Fail such PRs early via amannn/action-semantic-pull-request.
Blocker: the "every file rejected" reset never fired because the backend
returns `upload_id` unconditionally (with count 0), so `anyQueued` was
always true and the completion effect (gated on total > 0) never ran —
modal spun forever. Gate `anyQueued` on `count > 0` so a zero-photo
response takes the terminal reset path.
Concern 1: processing-stage failures were invisible — the modal
auto-closed on clean transfer before the worker reported them. Defer the
settle/close decision to the completion effect (combining transfer +
processing failures), and persist failed photos into `processingFailures`
state before `uploadIds` is cleared, so the rows don't vanish the instant
they appear.
Also: report card gets role="status"/aria-live (nit), and tests now cover
the whole-chunk transfer failure, the clean-settle path, and the
onUploadSettled contract from the real component.
- Persist the layout choice in the toggle click handlers instead of a
useEffect, so simply opening the Photos tab no longer re-writes the
value it just read from localStorage (review concern 1).
- Give the Grid/List toggle radiogroup/radio + aria-checked semantics
so a screen reader announces them as one mutually-exclusive set
(review concern 2).
- Add a test that mount performs no localStorage write.
Release Please Beta on v3.76.1-beta.0 hard-failed at the very first
`gh release view "$TAG"` call:
failed to run git: fatal: not a git repository
(or any of the parent directories): .git
The reusable `whatsnew-highlights.yml` (PR #703) doesn't run
actions/checkout — so when `gh` tried to infer the target repo from
the runner's empty workspace it errored out. The first time it ran
against an actual release (#709 → 3.76.1-beta.0), the whole job died
before the deterministic-fallback path could save it.
Two changes, both single-line:
1. `env.GH_REPO: ${{ github.repository }}` at job scope. `gh` honours
this and won't fall back to parsing `.git/config`, so no checkout
is needed (the workflow only calls the GitHub API, never reads
repo files).
2. `continue-on-error: true` on the "Extract Features" step. The
file's comments say "never let highlights break a release", but
the original wiring only soft-failed the AI + inject steps. A
transient API hiccup at extract still hard-failed the whole job —
defeating the design intent. Match the comment.
Why not just add actions/checkout? It would work, but pulls the whole
repo over the wire on every release just for `gh` to read its own
config. GH_REPO is the lighter idiom.
Net impact today: v3.76.1-beta.0 shipped without the `<!-- whatsnew -->`
block; the app's parseWhatsNew() already falls back to the raw Features
list so the admin "What's New" banner still works. The next beta release
will pick up the polished version.
The failure report lives inside the upload modal, but the modal
auto-closed the instant the transfer finished (handleUploadComplete →
onClose), unmounting the report before the user could read it — so the
"which files failed" list never actually appeared.
Split the modal's completion callback in two:
- onUploadComplete: refresh the grid only (no close), as bytes land and
again when processing finishes
- onUploadSettled({ hasFailures }): fired once the transfer settles; the
modal auto-closes only on a clean upload and stays open (report
visible) when any file failed
Also reset the transfer UI when nothing was queued (every file failed),
which previously left the modal spinning forever. Add a PhotoUploadModal
test covering close-on-clean vs stay-open-on-failure.
A partial upload only told the admin "some files failed" with no way to
find out which ones — even though the data existed. The backend already
returns per-file rejections (response.errors: [{filename, error}]) and the
progress hook already exposes failedPhotos, but both were dropped.
Add a dismissible failure report to the upload modal listing every file
that didn't make it into the gallery, grouped by stage with its reason:
- rejected: per-file validation rejections from the upload response
(previously discarded entirely)
- transfer: whole-chunk request failures (now captured with the error,
not just the filename)
- processing: background-worker failures from useUploadProgress.failedPhotos
Replace the count-only "some files failed" toast with one that points at
the list. Add en/de keys under upload.failures.* and a component test
covering the rejected + processing rows and dismissal.
The event detail Photos tab (AdminPhotoGrid) only offered a thumbnail
grid. Add a Grid/List toggle in the action bar so admins can scan
photos in a compact, metadata-oriented list.
- New utils/photoViewPrefs.ts persists the choice per admin via
localStorage (mirrors utils/calendarPrefs.ts), defaulting to grid
- List view is a compact <table> following the established admin
list pattern (EventsListPage), with responsive column hiding:
Photo (thumbnail + filename + original + Video/Hidden badges),
Category (lg+), Uploaded date (md+, via useLocalizedDate),
Engagement views/downloads/likes (xl+), Feedback rating/comments
(sm+), Size, and hover Actions (download, delete)
- Rows reuse the existing selection, download, delete and category
handlers; row click opens the photo viewer
- Toggle buttons use LayoutGrid / List icons with aria-pressed state
- Add en.json + de.json keys under admin.photos (viewMode, gridView,
listView, columns.*)
- Tests for the persistence util and the toggle's render + persistence
The Features-fallback showed raw changelog text, so a commit subject like
'branded URL shortener — /s/<slug> with OG injection' surfaced two problems
in the admin banner:
- release-please escapes <slug> to <slug>; React renders the literal
entity, so the banner read '/s/<slug>'. Decode the entities
(< > & " '), & last to avoid double-decoding.
- the technical tail leaked into a user-facing highlight. Drop a trailing
'— detail' clause (em dash only, so 'mark-paid' is untouched) so the bullet
reads as the headline 'branded URL shortener'.
Only affects the deterministic fallback; curated <!-- whatsnew --> blocks are
unchanged.
If GitHub Models is disabled for the org the ai-inference step errors;
without continue-on-error the job would go red and skip the inject+fallback.
Mark it continue-on-error so an unavailable Models cleanly degrades to the
deterministic bullets — the feature now works with Models off, not just on.
Activate the What's New highlights step that condenses each release's
Features into <=8 short bullets and injects a <!-- whatsnew --> block the
app reads (utils/whatsNew.parseWhatsNew), with a deterministic fallback.
Runs as a needs: job inside the release-please workflows rather than on a
standalone release: published trigger, because release-please creates the
release with GITHUB_TOKEN and GitHub never starts new workflow runs from
token-generated events -- a standalone trigger would never fire. Shared as
a reusable workflow_call so the stable and beta channels stay in sync.
Best-effort: continue-on-error + fallback mean it can never break a release.
Requires GitHub Models enabled for the org; until then the fallback is used.
Issue 3 from #699 (@alexvaltchev's report): expose a custom-named short
URL per event that bots scrape for OG previews and browsers redirect to
the underlying gallery. WhatsApp / iMessage / Facebook cache the OG
metadata by the URL they crawl, so the SHORT URL becomes the cache key
— admins can rotate or split-test underlying gallery URLs without
re-pushing a fresh link to clients.
Additive feature; no existing route, table, or column is modified.
## Backend
- `gallery_short_urls` table (migration 150): id, short_slug UNIQUE,
event_id FK CASCADE, target_path TEXT, created_by/at, hit_count,
last_hit_at, deleted_at/by. hasTable-guarded so the migration is
idempotent on re-run.
- `src/services/galleryShortUrlService.js` — validator + CRUD +
resolver. Slug rules: `/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/`,
reserved blocklist (admin, api, auth, gallery, og, s, login, ...).
target_path snapshots at create-time from the event + global
short-URL toggle, so a later flip of the toggle does NOT silently
change where existing short URLs resolve.
- `src/routes/adminShortUrls.js` — `GET/POST
/api/admin/events/:eventId/short-urls`, `DELETE
/api/admin/short-urls/:id`. Structured errors: 400 INVALID_SLUG,
409 SLUG_TAKEN (with `suggested`), 404 EVENT_NOT_FOUND. Gated by
events.view / events.edit + requireEventOwnership.
- `server.js` /s/:shortSlug public route. Bot UA → server-render the
same OG metadata the existing /og/gallery/<slug> handler produces,
then override og:url to point at /s/<shortSlug> itself (cache-key
invariant — social platforms key by the URL they scrape).
Browser UA → 302 to target_path. Soft-deleted slug → 410 Gone
(intentional-delete signal, distinct from 404 unknown slug).
Hit accounting is fire-and-forget.
## Frontend
- `services/shortUrls.service.ts` — list/create/remove.
- `components/admin/ShortUrlsCard.tsx` — per-event card on the
EventDetailsPage. Form for custom or auto-generated slug, list with
copy-to-clipboard + soft-delete. SLUG_TAKEN error surfaces the
service's `suggested` slug with a "use suggested" button.
- i18n: events.shortUrls.* added to EN + DE.
## Tests
78 new tests, all passing:
- `__tests__/utils/galleryShortUrlValidation.test.js` (48) — pure-
function tests for validateSlug: accepts/rejects, reserved-slug
blocklist, path-traversal + URL-injection vectors.
- `__tests__/integration/galleryShortUrls.test.js` (19) — service
layer against a real SQLite DB. Covers custom + auto-generated
slugs, collision + SLUG_TAKEN + suggested, target_path
snapshotting (backward-compat invariant), soft-delete + slug
rotation, hit counting.
- `__tests__/integration/galleryShortUrlRoute.test.js` (11) —
HTTP-level: 302 redirect for browser UA, 200 + OG HTML for bot UA,
og:url canonical points at /s/<slug>, 410 for soft-deleted +
orphaned events, 404 unknown + malformed.
Regression sweep: 47 existing migration-chain integration tests still
pass; migration 150 is additive only.
## Backward compatibility
- Existing `/gallery/<slug>`, `/gallery/<32-hex-share-token>`,
`/gallery/<slug>/show/<token>`, `/og/gallery/<slug>`,
`/og/gallery/<slug>/cover` routes are untouched.
- The `/s/` namespace is new; no existing route lives there.
- Migration 150 only ADDs the new table — no ALTERs on existing
schema, no destructive changes.
- target_path is snapshotted at create-time so flipping the global
"Use short gallery URLs" setting after a short URL exists does NOT
change where that short URL resolves.
Two SSR-OG injection bugs reported by @alexvaltchev. Both made his link
previews fall back to the brand logo + site-wide tagline instead of the
event-specific name/photo, even though the bot UA was hitting our
already-existing OG handler. He compensated with a Cloudflare Worker as
SSR middleware — which then created bug 3 below (og:image at the
auth-gated /api/.../hero/ path, not the public /og/.../cover one), so
Instagram never rendered the image either.
## Bug A — slideshow URLs miss the OG handler entirely
`/gallery/<slug>/show/<token>` has 3 segments after `/gallery/`. The OG
route was wired only at `/gallery/:slug/:token?` (1-2 segments), so
slideshow links fell through to the SPA-catchall `/gallery/*` and never
invoked the OG handler at all. Added a second route handler for the
3-segment slideshow shape, sharing the same intercept middleware so a
recognised social crawler still gets the rich preview.
## Bug B — share-token-only URLs resolve to nothing
`/gallery/<32-char-share-token>` (the form produced when migration 525's
short-URLs option strips the event slug) routes to the OG handler with
`slug=<token>`. resolveSlug then queries `events.slug = <token>`, which
never matches because the token is in a separate `share_token` column.
Result: falls through to the "no event found" branch and serves the
generic site-wide OG.
Fix: when the slug shape matches a 32-char hex AND the slug lookup
missed AND no redirect rule applies, try `events.share_token = slug` as
a final fallback. Real slugs are kebab/dot/underscore mixes, never pure
32-hex, so the extra DB roundtrip is gated to only fire for the
token-shaped URL.
## Tests
3 new tests in galleryOgService.shareImage.test.js using non-entropy
32-hex fixtures (deliberately zero-padded to avoid tripping
GitGuardian's Generic High Entropy Secret detector while still
matching the route's /^[a-f0-9]{32}$/i shape check):
- share-token slug resolves via the share_token column (alex's case)
- malformed/expired 32-hex token returns the site-wide fallback (no leak)
- non-hex slugs skip the share_token query entirely (hot-path cost guarded)
All 14 tests in the file pass.
## Out of scope here (separate follow-up)
- Issue 2 (Instagram og:image) — alex-side CF Worker bug pointing
og:image at /api/gallery/<slug>/hero/<id>, which requires gallery
auth. PicPeak already has the right unauthenticated path
(/og/gallery/<slug>/cover) gated by events.og_image_share_enabled
per-event opt-in (#474). Documented in the issue reply.
- Issue 3 (URL shortener with custom names) — real feature request,
meaningfully different from the existing #525 short-URLs option that
just strips the slug. Designing separately.
- you still bring your own server (own hardware or VPS) and optional domain.
- Pixieset "unlimited" storage is photos only — video is capped per plan (~0–10 h depending on tier).
- Renumber the PicPeak storage footnote (* → **) so the three markers don't collide.
Surfaces release highlights to admins, sourced from the GitHub release notes
(no AI at runtime). Bullets are written once per release in CI via GitHub Models
(see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app
reads that block and falls back to the changelog's "### Features" for releases
without it — so it works against today's releases immediately.
- backend utils/whatsNew.parseWhatsNew(body): curated block else Features
section, strips scope/PR-links, de-dups, caps at 8 (tested).
- GET /admin/system/updates/whatsnew: highlights for every version moved
through since the per-instance marker (whatsnew_last_seen_version); fresh
installs self-anchor silently. Best-effort, never errors.
- POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance).
- /admin/system/updates also returns latestHighlights for the teaser.
- Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on
the dashboard via adminService; UpdateNotification shows a "New features
include:" teaser. i18n de/en. No migration (uses app_settings).
Branch protection on `main` + `stable` lists `upgrade-from-bootstrap`
and `fresh-install` as REQUIRED checks. The producing workflows had
`paths:` filters in their `pull_request` triggers, so they correctly
skipped on PRs that didn't touch migrations / package.json. But a
skipped workflow doesn't satisfy a required check — it leaves the
status "missing", which blocks merge on every unrelated PR.
Concretely surfaced on PR #692 (security bumps): all 12 visible checks
were green, but the merge button was blocked because the two
path-filtered workflows skipped and their required-check names never
reported.
This PR drops the `paths:` filter from both workflows so they always
fire on PRs against `main` + `stable`. Costs:
- `schema-drift` (`upgrade-from-bootstrap`): ~75 s per PR (Postgres
service boot + migrate:safe run + schema assertion).
- `install-smoke` (`fresh-install`): ~2 min per PR (full Docker
Compose boot + login).
Both are buying unconditional safety nets on the install + migration
paths, which is what the required-check gate is supposed to model.
Also fixes the trigger branch list while in the file: `[main, beta]`
→ `[main, stable]`, completing the post-#669 rename for these two
workflows that were missed in PR #686.
## What this does NOT fix
`GitGuardian Security Checks` is the third required check that's
currently missing on PRs — but that's a separate problem. The
GitGuardian GitHub App was installed at the user-account level
(`the-luap`) before the org transfer and didn't move with the repo.
Re-installing it on the org via the GitHub Marketplace is a UI step
the maintainer needs to do; can't be done via API.
@Luca-Timo is on main's review-bypass list so he can self-merge small
bugfixes without waiting for a maintainer review. The bypass list alone
is binary (he can merge anything), so this adds a complementary required
status check that fails when a bypass user's PR exceeds a configured
line-count threshold — blocking merge for genuine features while leaving
small bugfixes flowing.
How it works:
- Trigger: pull_request_target (so the workflow runs in the base repo's
context with permissions to write a check status — script never
executes PR code, so fork-PR-attack-safe).
- For PRs authored by a bypass user (default: @Luca-Timo):
- linesChanged = additions + deletions
- If ≤ LINE_LIMIT (300): check = success → bypass works → self-merge OK
- If > LINE_LIMIT: check = failure → required-check gate blocks merge
regardless of bypass; needs a maintainer review.
- For everyone else: check = success ("not applicable"). They go through
the normal review path and are unaffected.
Both constants (LINE_LIMIT, BYPASS_USERS) are at the top of the workflow
for easy tuning.
After this lands on main, a separate API step adds 'bypass-size-gate' to
the main branch's required_status_checks list so the gate is actually
enforced. Until that's in place the check runs but doesn't block.
GitHub-flavored `> [!IMPORTANT]` callout right below the title, before
the badges/hero block, so it's the first thing a visitor or repo browser
sees in the rendered README. Mirrors the in-app banner (#687) so an
operator gets the same message whether they're browsing the repo or
logged into the admin dashboard.
Body covers:
- Image-path change with the literal new path
- Branch rename (beta → main, main → stable) with auto-redirect note
- Link to docs/migration-to-org.md for the exact compose-file edit
Remove (or downgrade to a regular note) after the migration window
settles, same lifecycle as the in-app banner constant.
One-time banner shown at the top of the admin layout to surface the org
rename + GHCR registry change for operators who haven't read the release
notes. Sits right below the existing maintenance banner — same pattern.
## What it looks like
Blue, dismissible banner with a short body:
> PicPeak's image registry has moved
> Update your docker-compose.yml to pull from
> ghcr.io/picpeak/picpeak/{backend,frontend} — the old path is no longer
> being updated. [See migration notes]
The link goes to `docs/migration-to-org.md` on the new org repo.
## Design choices
- **No backend feature flag.** A hard-coded `MIGRATION_BANNER_ENABLED`
constant in the component file (1 line) gates global display. After
~1 quarter, flip it to false (or drop the mount in `AdminLayout.tsx`)
in a small follow-up PR. A backend `app_settings` row + Settings UI
toggle would be overkill for a one-time migration event.
- **Per-admin dismissal via localStorage.** Key is `picpeak:migration-banner:v1`
(versioned so a future "we've moved AGAIN" banner can show without
inheriting earlier dismissal). Wrapped in try/catch so private-mode
browsers + storage-quota-exceeded errors don't crash the layout.
- **EN + DE strings** under a new top-level `migrationBanner` namespace.
Other locales (fr, nl, pt, ru) fall through to EN — `migrationBanner.*`
keys aren't translated there yet, deliberate (per #669 the ops
banner is operator-facing and admins reading EN/DE is the majority).
- **Reuses `common.dismiss`** for the close-button aria-label.
## Test plan
- [ ] Frontend `npm run build:check` passes (TS + build)
- [ ] Open admin dashboard in EN → banner shows at top, below header,
above main content
- [ ] Switch to DE → banner shows German strings
- [ ] Click dismiss → banner hides, doesn't re-appear on hard refresh
- [ ] Clear localStorage `picpeak:migration-banner:v1` → banner returns
- [ ] Flip `MIGRATION_BANNER_ENABLED` to false → banner doesn't render
for anyone, regardless of dismissal state
Refs #669.
After the org move + branch rename (#669):
beta → main (active development)
main → stable (curated release channel)
This PR rewires the workflows that referenced the old branch names so
release-please and the Docker build target the right channels.
## Workflow changes
### `.github/workflows/docker-build.yml`
- **Push triggers**: `[main, beta]` → `[main, stable]` (both `push.branches`
and `pull_request.branches`). `beta` no longer exists; `stable` is the
curated channel that should also produce builds.
- **`is_prerelease` detection**: pre-release context was decided by
`refs/heads/beta`; now decided by `refs/heads/main` (active dev →
prerelease, `-beta.N` version suffix unchanged).
- **`:latest` + `:stable` tagging**: were gated on `{{is_default_branch}}`
(which used to be `main` = stable channel). Default branch is now `main`
= active dev, so the implicit gate would have aliased `:latest` to dev.
Both tags now explicitly gate on `refs/heads/stable` OR a non-prerelease
release tag.
- **`:beta` tag**: REMOVED. Active-dev pulls are `:main` (auto-generated
by `type=ref,event=branch`). The pre-rename `:beta` tag remains frozen
at its last build under Option B / #669 — operators are expected to
update to `:main` or pin to a versioned tag.
### `.github/workflows/release-please.yml`
- `branches: [main]` → `branches: [stable]`. This is the **stable**
release-please workflow (uses `release-please-config.json`); after the
rename, the stable channel lives on the `stable` branch.
### `.github/workflows/release-please-beta.yml`
- `branches: [beta]` → `branches: [main]`.
- `target-branch: beta` → `target-branch: main`.
- This is the **pre-release** release-please workflow (uses
`release-please-config-beta.json`, `prerelease: true`); after the rename,
pre-releases are cut from the new `main` (active dev). The version-suffix
scheme stays `-beta.N` so existing operator pins keep working.
## RELEASING.md
Rewrote the TL;DR, "How a stable release is cut", and hotfix path to
reference the new branch names. Added a one-line "branch model background"
note pointing at #669 so future maintainers know why `main` means active
dev (the opposite of what some projects use). Filename conventions:
`release/X.Y.Z-merge-from-main` (was `…-from-beta`); promotion PR title
`promote main → stable as vX.Y.Z` (was `promote beta → main`).
## Why combined with PR A's content as a single PR
Originally planned as two PRs (B = workflow triggers, C = release-please
reconfigure). Splitting wasn't worth it: the configs are branch-agnostic
(`release-please-config.json` and `release-please-config-beta.json` don't
mention branch names internally), and not bundling them meant a window
where the stable release-please workflow would fire on pushes to the new
`main` (active dev) — exactly the wrong place. Single PR closes that gap.
## Versioning scheme — kept
No version-scheme decision needed. The `-beta.N` suffix on pre-release
versions is preserved (existing operator pins like `v3.71.3-beta.0` keep
working). If a `v4.0.0-pre.N`-style reset is desired later, that's a
separate PR with explicit operator-comms attached.
Operator + contributor docs for the post-org-move world. None of these
files reference the legacy branch names (`beta` / old `main` meaning) —
they describe the new shape (`main` = active dev, `stable` = curated
release channel), so they're correct from the moment the rename happens.
Three additions/edits:
1. `docs/migration-to-org.md` (new) — operator-facing one-pager that the
in-app migration banner + the OLD GHCR package URLs (now 404) can
point at. Walks through the single `docker-compose.yml` edit needed.
2. `CONTRIBUTING.md` — new "Branch model" section explaining which
branch to target (`main` for features + most fixes; `stable` only
for small, surgical bugfix backports). Updates the "fork from beta"
step to "fork from main". Updates the release-process paragraph to
describe the two-channel model instead of the old beta→main promote.
3. `.github/PULL_REQUEST_TEMPLATE.md` — adds a target-branch hint at
the top of the template (HTML comment so it shows during PR
composition but doesn't render in the merged PR body).
Repo transferred from the-luap/picpeak → PicPeak/picpeak. Docker images
publish to ghcr.io/picpeak/picpeak/{backend,frontend} (lowercase, per the
GHCR canonical form computed by docker-build.yml's `${GITHUB_REPOSITORY,,}`).
Sweep covers:
- docker-compose.production.yml + Dockerfiles → new image registry path
- README, CONTRIBUTING, SECURITY, SIMPLE_SETUP, scripts/picpeak-setup.sh
→ new GitHub URLs
- Update-check / release-notes services (updateCheckService,
environmentService, updateNotificationService, adminSystem,
UpdateNotification, githubReleaseUrl) → GitHub API + tag URLs use the
canonical PicPeak/picpeak path
- Issue templates + README-DOCKER + workflow README → updated package URLs
- One commit-context comment in migrations/090 + customerAccountsService
CHANGELOG.md is intentionally untouched (historical release entries are
immutable; GitHub auto-redirects the old URLs indefinitely).
CLAUDE.md keeps the bare `(the-luap)` reference — that's the maintainer's
personal handle, not a repo URL.
22 files, 48/48 line swaps (every change is a 1:1 URL replacement).
The earlier change only relabeled is_monthly_draft rows. But a per-event
invoice created from hours is status 'scheduled' with scheduled_send_at = NULL
and is_monthly_draft = false — it never auto-ships (the scheduler only picks
rows with scheduled_send_at <= now), yet it still read "Scheduled" on the
customer panel + lists.
Add a shared isDraftInvoice() helper (scheduled && no send date, or a
monthly/manual accumulator) and use it for the badge in the Bills list, the
invoice detail header, and the customer profile's invoice panel. A scheduled
invoice WITH a future send date keeps "Scheduled".
Per request, keep the dashboard to four tiles rather than adding a fifth: the
"Revenue · last 365 days" tile is now clickable and toggles in place between
the trailing-365-day window and calendar year-to-date (since Jan 1).
- adminDashboard: new calendar-year cutoff + revenue.calendarYearMinor (same
cash-basis paid_at window logic as the existing trio).
- StatCard gains an optional onClick (renders as a button); the year tile uses
it, with a "Tap to switch window" hint for discoverability.
- bills.service CrmOverviewStats.revenue gains calendarYearMinor.
The mark-paid dialog offered Cash / Card / PayPal / TWINT but not bank
transfer — the default method for the QR-bill / IBAN invoices picpeak issues
(createInvoice even falls back to 'bank_transfer'). Added it as the first
option. Backend already accepts paymentMethod as a free string, so no API
change; i18n bills.payment.methods.bankTransfer (de "Überweisung").
Follow-up to the Bills-list change: the invoice detail header still printed
"Scheduled" for a running monthly/manual draft (is_monthly_draft). It already
had a separate monthly-draft badge, but the status pill itself now reads
"Draft" too, matching the list and the Billed-chip link target.
Manual/monthly-cadence customers accumulate logged hours into one running
draft invoice (is_monthly_draft, migration 128). That draft gets a real
invoice number and stamps the hours ("Billed: R-2026-0026"), but listInvoices
hid is_monthly_draft rows from the main list — so the invoice looked lost even
though it existed on the customer's monthly-queue card. It also carried status
'scheduled' despite never auto-sending on manual cadence, reading misleadingly
as "Scheduled".
- Bills list now opts into drafts via a new `includeDrafts` query param
(GET /admin/invoices → listInvoices includeMonthlyDrafts). Pickers/sub-lists
that reuse billsService.list leave it off, so they're unaffected.
- Draft rows render a distinct "Draft" badge instead of "Scheduled"
(transformInvoice already exposes isMonthlyDraft).
- The hours "Billed: R-…" chip now links straight to its invoice.
- i18n: bills.status.draft (de "Entwurf", en "Draft").
eventReminderService used bare boolean literals in its knex .where() calls
(events.is_active/is_archived/event_reminder_disabled and the assigned-
customer c.is_active), instead of the codebase's formatBoolean() convention
(utils/dbCompat). On SQLite, booleans are stored as 0/1, so a bare `true`
relies on knex's coercion rather than the explicit helper every other service
uses — the maintainer flagged this twice (#674, #679). Wrap all four.
The live totals panel in the quote/invoice editor (LineItemsTable) summed the
per-line rounded totals and showed that as Total — so with crm_invoice_round_total
on, a 4 × (2.5h @ 32.25) invoice previewed CHF 322.52 while the saved invoice +
PDF correctly show 322.50 with a Rundung row. The preview now mirrors the backend.
- LineItemsTable gains a `roundTotal` prop. When set, it computes the clean net
(full-precision sum rounded once — same rule as backend
utils/invoiceRounding.cleanNetMinor, including the migration-119 priced
sub-item override), shows a "Rundung" row for the drift, and folds it into the
VAT base + Total. Off ⇒ unchanged (no row).
- Bill + Quote editors pass roundTotal from appSettings.crm_invoice_round_total.
- i18n: crm.lineItems.rounding (de "Rundung", en "Rounding").
The saved-invoice detail view already shows the stored clean total, so no change
there.
Per-line totals are each rounded to the cent before the net is summed, so
a long time-based invoice can drift a few Rappen from qty × rate — e.g.
68 h × 32.25 = 2193.00, but the 21 rounded line totals sum to 2193.02. This
is the standard "sum of rounded lines" convention (Stripe/QuickBooks/Xero
do the same) and it foots, but some issuers want the total to match the
customer's arithmetic.
New per-issuer setting `crm_invoice_round_total` (default OFF, no migration —
read via getAppSetting with a false default). When on, the create paths store
the full-precision net rounded ONCE (cleanNetMinor), and the drift is shown to
the reader as an explicit "Rundung" row:
Betrag Netto 2'193.02 (= Σ visible line totals, still foots)
Rundung -0.02
Gesamtbetrag 2'193.00
- New util src/utils/invoiceRounding.js (cleanNetMinor) mirrors the
migration-119 hierarchy (priced sub-items override their parent) but sums
at full precision; rate-agnostic, so mixed hourly rates reconcile to one
clean net. Single document-level VAT rate ⇒ one Rundung row.
- computeTotals (quotes) + createInvoice + payload-preview gain the toggle.
- Render contexts derive the row as storedNet − Σ(line totals); legacy/off
documents have equal values ⇒ adjustment 0 ⇒ byte-identical output.
Suppressed on Storno/Mahnung (negated net + sign-flipped lines).
- Storno/tax-report stay correct: both use the stored net scalar, which is
the clean value (createStorno negates net_amount_minor; it never re-sums).
- pdf-i18n: totals_rounding in all 6 locales (de/en/fr confident; nl/pt/ru
machine-translated — flag for native review).
- Frontend: toggle on Settings → CRM (Invoices), default off.
Tests: backend/__tests__/utils/invoiceRounding.test.js (real 68h invoice,
mixed rates, discounts, sub-item hierarchy, no-op case).
Before drawing the line-items table, the renderer inflated page 1's bottom
margin to reserve room for the bottom-pinned totals block, but the `finally`
restored it on whichever page the table *ended* on — leaving page 1
permanently short on any multi-page document. On long invoices and quotes
this caused:
- the table to break far too early (only ~6 items on page 1, large blank
gap beneath)
- the page-number stamp to land below page 1's phantom bottom margin,
auto-paginating a stray blank trailing page and desyncing the
"Seite X von Y" labels (page 1 unnumbered, the blank page labelled
"Seite 1 von N")
Let the table paginate with the document's normal margins so each page fills
to the bottom; the existing desiredTotalsY check already advances to a fresh
page when the last item row would collide with the pinned totals block.
Also suppress the IBAN block under the totals when a Swiss QR-bill slip is
appended: the slip already prints the account/IBAN in human-readable form,
so it was pure duplication. The EPC QR path keeps the block (its QR lives on
a trailing page, so on-page bank details still help).
The dedicated Approvals page rows open the underlying document on click, but the
identical card on the admin dashboard didn't — so 'clickable approvals' only half
worked depending on where you looked. Apply the same treatment: the info area is
now a button that navigates to the run entity's detail page (quote -> /admin/quotes/:id,
invoice -> /admin/bills/:id, etc.), reusing the workflows.approvals.openEntity
tooltip. Confirm/Deny stay separate; items with no mappable entity render as plain
text.
The customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
(default 15) before it locks, and the public page promises exactly that. But the
booking workflow fired on the FIRST accept click and immediately converted the
quote (status -> 'converted'), so a decline within the window was rejected
('Quote cannot be responded to in status converted') — the grace period was dead
on arrival.
recordResponse / adminAcceptQuote now DEFER the workflow emit while the toggle
window is open; the new scheduler sweep finalizeQuoteResponses fires the FINAL
status once response_locked_at passes (idempotent via the new
quotes.workflow_response_emitted_at column, migration 149). A response recorded
with the window already closed (0-min window, or admin decline which locks
immediately) still emits inline. So toggling accept->decline->accept inside the
window converts at most once, for the final state, after the customer's grace
period — and a plain decline never converts.
Trade-off: with the hourly CRM scheduler, the booking flow now starts up to ~1h
after the window locks instead of instantly. Acceptable — the flow gates on admin
review anyway, and the alternative (graph-level wait) wouldn't reach already-
enabled built-ins (admin_toggled_at blocks re-seed).
Adds a finalize sweep test (deferred while open, fires + stamps once locked,
idempotent).
Two entry points for event creation were missing customer notifications, both
discovered while triaging @Rekoo-PS's report that "API created events" don't
send WhatsApp after #649/#650 landed.
POST /api/v1/events (the OpenAPI-spec'd bearer-token API at v1/events.js):
- gallery_created email was NEVER queued — only the webhook fired.
- WhatsApp was NEVER queued either.
POST /api/events (legacy admin-auth route at routes/events.js):
- gallery_created email was queued, but WhatsApp was not.
- customer_phone wasn't read from the body at all.
Both routes now mirror the adminEvents.js create-and-publish path: best-effort
queues that never block the API response, gated on customer_email / customer_phone
presence and the global event_phone_field_enabled toggle for the phone field.
The webhook subject from POST /api/events now also includes customer_phone, so
downstream integrations get the same shape as the v1 API.
No schema change. No migration. customer_phone column already exists on events
(migration 080). WhatsApp config + template_language + template_params resolve
through the existing queue processor.
A quote with no explicit payment timing falls back to a single after_delivery
installment. spawnInstallmentInvoices marked those 'pending_delivery' even in
hold mode, so the booking flow's send_document -> sendInvoice threw 'Cannot send
invoice with status pending_delivery', the run failed, and no invoice email went
out (the symptom: approve the quote->invoice flow, receive nothing).
In hold mode the flow's review gate + explicit send_document IS the delivery
release, so a held invoice is always 'scheduled' (editable + sendable) regardless
of trigger; scheduled_send_at stays null so the scheduler never auto-sends it.
Non-hold after_delivery invoices keep 'pending_delivery' as before.
Adds a regression test (default after_delivery term -> draft -> scheduled+null).
Each approval asks the admin to confirm/deny, but they couldn't see what they
were approving. The row's prompt/meta area is now a clickable button that
navigates to the run entity's detail page (quote -> /admin/quotes/:id, invoice
-> /admin/bills/:id, event/contract/customer likewise) so the admin can review
before deciding. Confirm/Deny stay as separate buttons; rows whose entity has no
detail route (or no entity) render as plain, non-clickable text. Adds the
approvals.openEntity tooltip string (en + de).
These were the last guard-stubbed actions — offered in the builder palette but
refused on enable. Now all three are real, backed by existing converters:
- prepare_gallery: alias of prepare_event (a gallery IS an event in picpeak).
- reserve_date: convertToEvent({ skipInvoices: true }) — a pure draft date hold
with no money documents (new skipInvoices option on convertToEvent).
- prepare_quote: createQuote (customer entity) or duplicateQuote (quote entity),
producing a status='draft' quote; idempotent via ctx.vars.preparedQuoteId.
With no stubs left, the enable-guard switches from a hardcoded DOCUMENT_ACTIONS
list to a registry lookup: an action node whose config.action has no registered
handler is unimplementable. This can't drift from what the engine can run and
also catches typo'd/future actions. (Fixes the enable-route node mapping to
carry node.type so the action-node filter matches.)
Extends the single-connection SQLite in-trx deadlock fixes to the quote-create
path (prepare_quote runs unattended): nextQuoteNumber reads getAppSetting
through trx, createQuote logs via trx and hoists its hasColumnCached schema-drift
checks before the transaction.
Adds tests for reserve_date (no invoices), prepare_quote (draft, no deadlock),
and registry coverage; retargets the enable-guard refusal test at a genuinely
unregistered action. Full backend suite: 985 passed, 1 skipped.
The booking_full / booking_simple flows go prepare_event -> prepare_invoice,
but prepare_event was still a guard-stub, so enabling either flow returned
409 'uses actions that aren't implemented: prepare_event'.
prepare_event now calls convertToEvent({ hold: true }): convertToEvent already
creates the event as is_draft=true AND schedules its invoices, so this creates
those invoices on HOLD (scheduled_send_at NULL) and stashes their ids in
ctx.vars.preparedInvoiceIds. The downstream prepare_invoice already short-
circuits on a populated preparedInvoiceIds, so it ADOPTS the event's held
invoices instead of calling convertToInvoiceOnly again (which would both
double-create and throw ALREADY_CONVERTED_TO_EVENT). The review gate, the
wait-until-event-date, and send_document then issue those same invoices.
send_document(event)=publish is intentionally left a graceful skip — the
gallery is published manually after photos are uploaded, not auto-published
on an empty draft.
convertToEvent gains the same single-connection SQLite deadlock fixes as
convertToInvoiceOnly (getAppSetting reads through trx; logActivity moved after
commit) since prepare_event runs unattended, returns invoiceIds (incl. the
idempotent already-converted re-entry, which recovers them by event_id), and
removes prepare_event from the enable-guard list.
Adds a convertToEvent hold-mode test (draft event + held invoices + quote
linkage) and updates the enable-guard test to a still-stub action
(prepare_gallery). Full backend suite: 982 passed, 1 skipped.
Implements the draft-seam booking cutover so the booking_invoice_only flow
becomes enableable. The booking flows trigger on quote.accepted, so the run
entity is the quote:
- prepare_invoice: convertToInvoiceOnly({draft:true}) creates the invoice(s)
on HOLD (scheduled_send_at NULL, status stays 'scheduled') so the scheduler
never auto-sends before the review gate; crash-recovery recovers drafts by
the quote's deal_uuid. Stores ids in ctx.vars.preparedInvoiceIds.
- prepare_contract: createFromQuote (idempotent via converted_contract_id).
- send_document: dispatches the prepared draft (invoice -> sendInvoice each id,
contract -> sendContract).
- resolveActor: quote creator -> workflow creator -> first admin.
- prepare_contract/prepare_invoice/send_document removed from the enable-guard
list; prepare_event/prepare_quote/prepare_gallery/reserve_date still guarded,
so booking_full/booking_simple stay blocked until the event-path increment.
Fixes a latent single-connection SQLite deadlock these unattended paths would
hit: getAppSetting/logActivity/adminActor read or write the global db, which
deadlocks when issued inside an open knex transaction. Thread the active trx
through getAppSetting, logActivity, nextInvoiceNumber, nextContractNumber, the
spawnInstallmentInvoices audit log, and hoist adminActor before createFromQuote's
transaction. convertToInvoiceOnly now logs after commit and returns invoiceIds.
Adds bookingCutover integration test (hold-mode null send-at, normal scheduled
contrast, contract path no-deadlock) and a route test that the now-implemented
booking invoice actions can be enabled.
handleSubmit required a password whenever requirePassword was true, but the
password field only renders on the inline-email path (requirePassword &&
customerEmail). For a password-protected gallery with no inline email the field
was hidden, so submit blocked on the missing password and the dialog never
closed. Gate password collection + validation on a single `needsPassword`
(requirePassword && customerEmail); the no-inline-email path publishes without
re-entering the password (existing hash kept, customer reaches it via portal).
Publishing a gallery with no inline customer_email but assigned customer
account(s) previously sent nothing (the dialog said "no notification"). Now the
publish route falls back to the existing customer_gallery_assigned "your
galleries" email (sent per assigned active account in their preferred language)
so registered customers learn the gallery is available. Inline-email path
(gallery_created) is unchanged.
The publish dialog now reflects this: with an inline email it notifies that
address; with only assigned accounts it says the account(s) will be notified;
with neither, the button is just "Publish" (no false notify promise). Exports
notifyCustomerOfNewAssignments; EN/DE strings added.
When an event has no inline customer_email/host_email but has customer
account(s) assigned (event_customer_assignments), the pre-event reminder now
sends to those registered customers instead of skipping with no_recipient.
Recipients sent to an assigned account are queued WITHOUT eventId so the
language resolver uses the customer's preferred_language (vs the event's
language for inline-email sends). Applies to both the flow path
(sendReminderForEvent) and the legacy pass. The gallery-ready mail deliberately
does NOT fall back to accounts — only the reminder does. Test covers the
no-inline-email + assigned-customer case.
Language priority is event.language → customer preferred_language → app default
→ … → en, but it was keyed on email_data.eventId, which only queueEmail injects.
Direct email_queue inserts (e.g. the gallery-publish "notify customer" path) set
the event_id COLUMN but not email_data.eventId, so those mails skipped
event.language and fell through to the default — e.g. a gallery-ready mail in EN
while the same event's pre-event reminder (sent via queueEmail) was DE.
The processor now backfills emailData.eventId from the authoritative event_id
column before rendering, so every send path resolves language from the event
consistently.
composePayload pre-formatted event_date to DD.MM.YYYY, but emailProcessor runs
date variables through formatDate(value, language) — new Date("25.06.2026")
can't parse → the email rendered "Invalid Date". Pass the raw event_date and let
the processor localise it, matching the expiry mailer's contract. Pre-existing
in the migration-143 composePayload (dormant while the legacy pass was gated
off); surfaced once the pre_event_email flow ran.
Replaces the one-shot guarded POST with the maintainer's intended end-state: the
webhook node now references a CONFIGURED webhook subscription (Settings →
Webhooks) and enqueues a real webhook_deliveries row via
webhookService.enqueueForWebhook. Delivery then rides the existing worker
pipeline, inheriting — not reimplementing — per-delivery SSRF re-validation
(validateExternalUrl / GHSA-wmjx-pc37-272r), HMAC signing with the
subscription's secret, retry/backoff, and the deliveries audit log.
- webhookService.enqueueForWebhook(webhookId, eventType, data): enqueue for one
active subscription, bypassing fire()'s event-type matching. No schema change.
- webhook action: config.webhookId; unset/missing/inactive → observable skip;
dry-run does not enqueue. event_type = workflow.<trigger>.
- Editor: webhook node config is now a subscription dropdown (was a raw URL),
fed by the admin webhooks list, with a hint pointing to Settings → Webhooks.
- EN/DE strings; test asserts enqueue + dry-run no-op + inactive skip.
Reporter @the-luap hit the German `Veröffentlichen & Kunden benachrichtigen`
button overflowing the modal footer in the publish dialog. Two failure modes
chained:
1. The footer was a `flex` row with two `flex-1` buttons inside a
`max-w-md` (448 px) modal. Default `min-width: auto` on flex children
meant the primary button kept its content width (~340 px including the
paper-airplane icon + padding) and pushed the row past the modal frame.
2. Adding `min-w-0 whitespace-normal` doesn't help — the base `.btn` class
has `@apply ... whitespace-nowrap` (`index.css:149`) which wins over a
utility className via the Tailwind CSS cascade order. So the text won't
wrap, the button silently extends past the modal frame, no overflow
indicator. Verified with `getComputedStyle().whiteSpace = 'normal'` and
the button still rendering as one ~340 px wide line at ~224 px allocated
space.
Fix: stack both buttons vertically (`flex flex-col-reverse gap-3`). Primary
appears on top visually (col-reverse), cancel below — standard
confirmation-dialog pattern (Material, Headless UI, Radix all do this for
single-action dialogs). Works in every locale and viewport regardless of
label length. No side-by-side row to overflow.
Tried two prior shapes that didn't hold:
- `flex-col-reverse sm:flex-row` with `sm:flex-1 min-w-0 whitespace-normal`
on the primary: still overflowed silently because of the
whitespace-nowrap cascade above.
- `flex-col-reverse sm:flex-row sm:justify-end` with content-width buttons:
`justify-end` doesn't constrain a row whose content sum exceeds the
container; row just pushes left of the modal.
Bumping the modal to `max-w-lg` (or wider) was also considered and rejected:
matching modal width is asymmetric (every other admin dialog stays at
`max-w-md`), and any locale longer than German would re-hit the wall.
Stack-always is the only shape that handles every locale + every viewport
without per-language tuning.
Verified end-to-end against a dockerised dev backend:
- DE + EN × desktop (1280px) + mobile (375px) — all four show primary on
top, cancel below, both inside the modal frame, no overflow.
Lint + tsc + full vitest suite (84/84) clean.
Closes#670.
Second-review loose end: the `webhook` node type passed validation but had no
registered handler → engine dispatched to registry.getAction('webhook') →
undefined → every run silently skipped. An enabled webhook flow no-op'd.
Register a real `webhook` action (covers both the webhook node type and the
"Call a webhook" action). It POSTs the run context to config.url, guarded by
validateExternalUrl — the same NAT64/private-range SSRF protection the webhook
delivery worker uses (GHSA-wmjx-pc37-272r) — with no redirects and a timeout,
unless WEBHOOK_ALLOW_PRIVATE_URLS=true (local-dev opt-out). Missing URL /
rejected URL / network error record an observable skipped step, not a crash.
So the action is now implemented → it passes the enable guard legitimately.
Test covers dry-run, missing-url, and metadata-IP (169.254.169.254) rejection.
Renaming an event type's slug_prefix is editable in the UI but previously
orphaned everything keyed on the old slug: existing events/quotes (their
event_type) detached, and the authored per-type pre-event reminder template
(event_reminder_<slug>) was left behind → reminders fell back to default.
updateEventType now cascades atomically when the slug changes: re-points
events.event_type + quotes.event_type old→new and renames the
event_reminder_<old> template to <new> (guarded so it never clobbers an existing
target). So a photographer can rename a type to e.g. "concert" and the edited
subject/body follow. Column check resolved before the transaction (avoids the
SQLite global-read-in-trx deadlock). Tests cover the cascade + no-clobber.
The reminder template family (prefix) is now chosen on the notify_pre_event
block via config.templateGroup (default 'event_reminder'); within that group the
exact template is still auto-resolved per event type:
<group>_<eventType> if authored → else <group>_default
So an admin can point a flow at a different reminder family, while wedding/
birthday/… routing and the catch-all fallback stay automatic. resolveTemplateKey
now takes (eventType, group) and tolerates a trailing "_" on the group.
Editor: notify_pre_event (+ the gallery notify actions) added to the action
dropdown, with a "Reminder template group" field and hint. Seed sets
templateGroup='event_reminder' on the built-in (v4). EN/DE strings. Tests cover
the per-type / group-default resolution.
The reminder query joined customer_accounts on events.customer_account_id — a
column the events table doesn't have (events store the recipient inline as
customer_email/host_email, like the gallery emails). So the query threw, the run
failed, and no pre-event email went out for an event that has an email but no
CRM customer account. Latent in the legacy pass (gated off by default); surfaced
the moment the pre_event_email flow ran notify_pre_event.
Both runEventReminderPass and sendReminderForEvent now read the recipient from
the event's own columns (customer_email || host_email, name from
customer_name || host_name) via SELECT events.* — no join, safe on installs
predating the customer_email column. Regression test covers an event with a
direct email and no customer account.
Concern #2: switch eq/neq to ===/!== (drop the eslint-disable); a filter
{value:0} no longer matches false/''/null. Comment corrected — no implicit
type normalisation; filter authors match the payload type.
- gate decision with no matching edge → run fails (not silent done)
- enabled-based mutex: legacy reminder pass stands down only when the flow is on
- built-ins now seeded disabled (v6/v2/v3); re-seed flips never-touched defaults
but preserves an admin_toggled_at-owned flow
- route: rejects unknown node type; refuses enabling a flow with unimplemented actions
Confirm dialog on the list page when toggling a built-in OFF, clarifying it
reverts to the previous built-in/legacy behaviour rather than turning the
automation off (review concern #4). The enable-refusal for unimplemented flows
surfaces via the existing toggle error toast (backend 409). EN + DE strings.
Review concerns #1/#2/#3/#5:
- validateGraph whitelists node types (rejects a typo'd 'actoin' that would
no-op every cycle).
- Caps graph size: max 200 nodes / 500 edges / 16KB per-node config — a
workflows.manage user can't DoS the DB with a giant graph.
- Refuses to enable (create/update/PATCH) a flow whose graph references
unimplemented stub actions (the booking prepare_*/send_document), with a
clear 409, so an admin can't enable a flow that silently drops the work.
- Stamps admin_toggled_at on admin enable/disable/edit (sentinel for the seeder).
matchFilter strict-equality fix lives in the engine commit.
Per review: the four cutover built-ins (dunning, gallery_expiring,
gallery_expired, pre_event_email) now ship enabled:false. The mutual-exclusion
guards revert to ENABLED-based (isBuiltinFlowActive, not existence) so the
legacy paths keep running until the admin enables a built-in — enabling cuts
over, disabling reverts to legacy (fixes concern #4's "disable = silent dark"
foot-gun; no automation goes dark on upgrade).
admin_toggled_at sentinel (migration 148) marks admin ownership; the boot
re-seeder applies a shipped default-flip (enabled→disabled) only to
never-touched built-ins and never overwrites an admin's enable/disable/edit
(nit #1). SEED_VERSIONs bumped so the disabled default propagates.
Nit: applyReminder unlinks the just-rendered Mahnung PDF if queueEmail throws
(no orphan file).
Blocker #1: GET /workflow-approvals/:token/:action no longer mutates. Email
clients + security scanners (Outlook Safe Links, Gmail, Proofpoint, AV
link-checkers) GET links before the human clicks, which previously advanced a
payment-confirm gate silently. GET now renders a confirm/deny interstitial via
a new read-only peekApproval(); only POST calls actByToken.
Blocker #2: a gate decision with no matching edge now failRun()s instead of
finishRun(). resumeRun matches the decision handle EXACTLY (no fall-back to
outEdge's sole-edge heuristic), so a 'deny' with only a 'confirm' edge fails
loudly in run history instead of taking the confirm path / a green 'done'.
A quote can now choose which flow runs on acceptance instead of every enabled
quote.accepted flow firing. Migration 147 adds quotes.booking_workflow_id; the
editor shows a "Booking workflow (on acceptance)" dropdown listing the
quote.accepted flows (workflow-engine flag only); emitQuoteEvent passes it as
the new emitWorkflowEvent targetWorkflowId so ONLY the picked flow runs (still
gated on enabled + trigger match → a disabled/None selection runs nothing).
Adds the booking_invoice_only built-in (quote.accepted → prepare invoice →
review gate → send; no event/gallery, no wait), the variant requested for
shoots billed without an online gallery. Disabled stub like the other booking
flows until the prepare_*/send_document cutover.
Tests: targetWorkflowId runs only the selected flow; invoice-only built-in has
no wait/prepare_event.
The fee accumulates on every fee-bearing reminder (2nd onward), not just the
2nd — relabel the toggle (EN + DE + code fallback) to match the behaviour.
Implements the hybrid scope agreed on in #663: two native adapters
(Umami + Rybbit) for trackers we'd keep maintained, plus a Custom
script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4,
GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible
native, deeper metrics) explicitly deferred until someone asks.
## Architecture
**Backend `services/trackers/`**:
- `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` →
`{ desktop, mobile, tablet } | null`. Null = route falls back to
access_logs heuristic.
- `umamiAdapter.js` — extracted from the `services/umamiClient.js`
that landed in #662. Same 10 test contract preserved.
- `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension=
device` with Bearer auth, accepts both bare-array and `{data:[...]}`
envelope variants, tolerates `sessions`/`visitors`/`value`/`count`
metric keys.
- `customScriptSanitiser.js` — sanitize-html with a tracker-tight
allowlist (`<script>` / `<noscript>` / `<link rel=preconnect|
dns-prefetch>` / `<meta>`). Strips event-handler attributes,
`javascript:` and `data:` URLs.
- `index.js` factory: `resolveAdapter()` reads
`analytics_tracker_provider` setting → dispatches. Back-compat:
when provider is unset, infers `umami` from the legacy
`analytics_umami_enabled` flag so #662 installs keep working
without an admin touching settings.
**Backend routes**:
- `adminDashboard.js /analytics`: now goes through `resolveAdapter()`.
Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js`
and its test file deleted (replaced by the adapter shape).
- `adminSettings.js PUT /analytics`: validates the new
`analytics_tracker_provider` enum, sanitises any incoming
`analytics_custom_head_html` on save via the sanitiser. Masks
the new `analytics_rybbit_api_key` on every GET — same pattern as
Umami's API key and recaptcha secret.
- `publicSettings.js`: emits `analytics_tracker_provider`,
`rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and
the pre-sanitised `analytics_custom_head_html` (only when
provider=custom). Legacy `umami_*` fields stay for back-compat.
**Frontend**:
- `analytics.service.ts` reworked into a provider-aware shape.
`initialize({provider, ...config})` dispatches to Umami /
Rybbit / Custom / None. `track()` calls dispatch to
`window.umami.track` / `window.rybbit.event` / no-op based on
the loaded provider.
- `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider`
from public-settings and routes to the right `initialize` call.
Legacy `umami_enabled`-based path preserved as fallback when the
new field is missing.
- `AnalyticsTab.tsx` (Settings → Analytics) reworked with a
"Provider" dropdown switching between None / Umami / Rybbit /
Custom panels. Each panel renders its own config fields; Custom
panel surfaces an explicit CSP-reminder banner.
- `useSettingsState.ts` shape extended with `tracker_provider`,
`rybbit_url`/`rybbit_website_id`/`rybbit_api_key`,
`custom_head_html`. Save mutation keeps `umami_enabled` in sync
with `tracker_provider==='umami'` for back-compat with downstream
consumers (publicSettings shape, embedded iframe).
- `publicSettings.service.ts` type extended.
**i18n**: EN + DE for the provider heading + description + dropdown
options + Rybbit fields + Custom HTML field + CSP warning.
## Custom mode — script execution caveat
When the gallery `<head>` receives the custom HTML, simply assigning
innerHTML to a container element wouldn't execute the embedded
`<script>` tags (per the HTML spec, dynamically-inserted scripts via
innerHTML are non-running). `analytics.service.ts:120-130` re-creates
each `<script>` element manually so the browser actually evaluates
it. Non-script nodes (link, meta, noscript) move in directly.
## Tests
**Backend** (42 cases, all pass locally):
- `umamiAdapter.test.js` (10) — pinned from the original
`umamiClient.test.js`: missing-config / URL shape / encoding /
payload normalisation / `laptop`→`desktop` / unknown buckets /
empty / non-2xx / invalid JSON / network error.
- `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit:
bare-array + envelope payload, `sessions`/`visitors`/`dimension`
key tolerance, encoding, failure modes.
- `trackerFactory.test.js` (6) — resolves null for `none`/`custom`,
correct adapter for `umami`/`rybbit`, back-compat path via
legacy `analytics_umami_enabled`, garbage-provider defensive null.
- `customScriptSanitiser.test.js` (12) — Plausible-style passthrough,
Umami-style passthrough, inline body passthrough, `<noscript>`
allowed, `<link rel="preconnect|dns-prefetch">` allowed,
`<link rel="stylesheet">` stripped, disallowed tags stripped,
`javascript:`/`data:` URLs stripped, `on*` event handlers
stripped, defensive on malformed input.
- `analyticsDateMerge.test.js` (5) — preserved from #662.
**Frontend**: full 84-case vitest suite green; tsc + eslint clean
on changed files. Adapter changes are narrow refactors of code
covered by backend tests; no new analytics-page unit test added.
## End-to-end smoke (dockerised backend + my changes mounted)
```
test 1 (back-compat: no provider, umami_enabled=true)
→ factory returns umami adapter, /analytics returns
devicesSource:access_logs (umami fetch to fake host fails
gracefully). ✓
test 2 (invalid provider value)
→ 400 "analytics_tracker_provider must be one of: none, umami,
rybbit, custom" ✓
test 3 (save custom HTML with XSS payload)
→ stored sanitised:
`<script>alert(1)</script>evil<script async defer
data-domain="x.com" src="https://plausible.io/js/script.js"></script>`
(<div> stripped; script tags survive but CSP `script-src 'self'`
still blocks inline + non-allowlisted external at runtime) ✓
test 4 (public-settings exposes the provider switch)
→ `analytics_tracker_provider: 'custom'`,
`analytics_custom_head_html: '<sanitised>'` ✓
```
## Out of scope (next discussions)
- **Plausible native** — covered via Custom mode for now; native is
Phase 2 if someone explicitly asks.
- **CSP "trusted domains" admin input** — Phase 1.5. For now operators
add their tracker domain to nginx/proxy CSP manually; the new
CSP-reminder banner in the Custom panel makes that clear.
- **Refactor `(window as any).umami.track(...)` direct calls** in
PhotoLightbox/PhotoGrid to go through `analyticsService.track()`
so events fire on the right tracker. Currently a no-op when Umami
isn't loaded; functional but not optimal.
Closes#663 Phase 1.
The last-resort fallback hardcoded 'wedding', which breaks when the admin has
disabled that type. resolveDefaultEventType now prefers the generic 'other'
catch-all when active, else the first active type by display order, and only
uses a literal as a final guard if the catalog is empty/unreadable. The chosen
quote type and the crm_default_event_type setting still take precedence.
Quotes now carry an event type (migration 146: quotes.event_type, the
event_types.slug_prefix), chosen from the active event-types catalog in the
quote editor's Event section. convertToEvent reads it instead of the
unconditional hardcoded 'wedding': quote.event_type → crm_default_event_type
setting → 'wedding' as last-resort seeded fallback. When the booking flow's
prepare_event is wired, it reads the same field.
Backend: createQuote/updateQuote persist event_type (hasColumn-guarded);
adminQuotes route accepts + returns eventType. Frontend: FormState + payload +
load + a catalog-sourced dropdown ("— Use default —"); EN/DE strings.
Reporter @alexvaltchev hit three independent bugs on the Analytics
Dashboard. All three fixed in one PR; pluggable-tracker support
(Rybbit, Plausible, etc.) left for a separate discussion.
## Bug A — Summary cards showed 0
Two layers, both fixed.
**Frontend** (`AnalyticsPage.tsx:142-149`): the cards summed
`chartData[].views/uniqueVisitors/downloads`. The backend now (and
already) emits a dedicated `totals` object computed via separate
COUNT queries, which is what the cards should read. Postgres returns
counts as strings, so coerce via `Number()`.
**Backend** (`adminDashboard.js:268-282`): the chartData merge used
`dateObj.date === row.date`. On Postgres, pg's driver auto-converts
`DATE(timestamp)` to a JS Date object — the string-equality match
failed silently and `chartData` stayed all-zero on every Postgres
install with traffic. Added a `normaliseDateKey()` helper that
returns YYYY-MM-DD regardless of driver shape, plus `Number()`
coercion on the counts. SQLite path unchanged.
## Bug B — "Umami Not Configured" banner despite valid config
`AnalyticsPage.tsx:90` did `settings.reduce(...)` on the
`/admin/settings` response. That endpoint returns a
key/value **object** (verified at `adminSettings.js:108-149`), not
an array, so `.reduce` threw `data.reduce is not a function` and
the catch silently rendered the "Not Configured" banner even on
perfectly-configured installs. Read the umami keys directly off the
response object.
## Bug C — Device breakdown 0/0/0
Two-pronged fix.
**Primary path — Umami device API** (`services/umamiClient.js`,
wired into `adminDashboard.js`). When the admin provides an Umami
v2 API key (new setting `analytics_umami_api_key`), the backend
fetches the per-period device breakdown from Umami's
`/api/websites/:id/metrics?type=device` endpoint. Umami tracks
devices natively — far more accurate than our coarse user-agent
heuristic. The new `devicesSource` field in the response lets the
UI hint at where the numbers came from.
**Fallback hardening — local heuristic** (`adminDashboard.js:296-320`).
The existing access_logs `LIKE '%Mobile%' / '%Tablet%'` query stays
in place as a fallback for installs without Umami. Hardened with:
`whereNotNull('user_agent')` skips rows we never captured a UA on,
`Number()` coercion on COUNT results (pg returns strings), and a
guard against divide-by-zero when access_logs is empty.
## API key handling
Mirrors the existing recaptcha-secret pattern: stored plaintext in
`app_settings`, masked as `••••••••` on every GET via the existing
`adminSettings.js` GET handlers, and the frontend save mutation
silently drops the masked sentinel so re-saving without typing a
new key preserves the stored value.
## End-to-end smoke (dockerised backend with my fixes applied)
```
chartData total views: 27 ← previously 0 (date merge broken on PG)
totals: {'views': '27', 'downloads': '3', 'uniqueVisitors': '1'}
devices: {'desktop': 100, 'mobile': 0, 'tablet': 0} ← was 0/0/0
devicesSource: access_logs ← falls back correctly
analytics_umami_api_key (GET /settings/analytics): ••••••••
```
## Tests
**Backend** (15 new cases):
- `umamiClient.test.js` (10): missing-config → null, URL shape +
`x-umami-api-key` header, websiteId URL-encoding, `{x,y}` →
percentages, `laptop` → `desktop` mapping, unknown buckets
dropped, empty payload → null, non-2xx → null, invalid JSON →
null, network error → null.
- `analyticsDateMerge.test.js` (5): YYYY-MM-DD string pass-through,
ISO timestamp slice, JS Date (pg shape) → YYYY-MM-DD, null/empty
→ null, coercion for unexpected types.
**Frontend**: full 84-case vitest suite still green (no analytics
unit tests existed before; not adding any here — the changes are
narrow and the unit-level confidence comes from the type system +
the backend smoke above).
Closes#661 (bugs A + B + C). Rybbit / pluggable tracker support is
the next conversation per the issue author's follow-up.
- Booking built-ins reordered: prepare the invoice EARLY (admin adjusts line
items), admin approves at the review gate whenever, then the wait holds
dispatch until the event date and it sends itself. prepInvoice → reviewInvoice
→ waitEvent → sendInvoice (both booking_full and booking_simple; v3).
- Flow editor now reads/edits/saves trigger_config; the pre-event "days before
event" lead time is editable in the canvas toolbar (was only in settings,
which the cutover removed — closing that gap).
- Dashboard: pending-approvals card under "Events Expiring Soon" (workflows flag
+ non-empty only), with inline Confirm/Deny.
Confirms the design: a gate's confirm edge can feed a wait, so an admin OK
before the event parks the run at the wait and the scheduler dispatches on the
date. New test covers confirm-early-then-wait-dispatches.
Seed gallery_expiring / gallery_expired built-ins and make the live automations
flow-owned, with zero feature loss:
- New delegating actions (notify_gallery_expiring / notify_gallery_expired /
notify_pre_event) call the EXISTING send functions, so the engine path is
byte-identical to the legacy hourly checker/pass (same templates, recipients,
variables, dedup, per-event overrides, sent_at idempotency).
- Cutover built-ins (invoice_dunning, gallery_expiring, gallery_expired,
pre_event_email) now ship ENABLED; booking flows stay disabled (stubs).
- The legacy paths stand down via existence-based isBuiltinFlowPresent guards:
once a built-in is seeded (flag on) the engine is the single switch — flow
enabled = it sends, flow disabled = off — so no double-send and reminders/
expiry emails can still be fully turned off.
- emitDueEventReminders now honours the per-event reminder controls
(disabled / offset / sent_at) so pre-event timing is faithful; fixed a
Number(null)===0 offset bug.
Settings UI cutover is gated on the `workflows` flag (default off): when the
engine is live, the dunning reminder schedule (CRM settings) and the pre-event
global toggle (Reminder emails) are replaced with a "now in Workflows" callout;
when it's off, the legacy controls stay so flag-off installs lose nothing. The
late-fee math and installment-trigger defaults stay (fee math / scheduler-owned).
Split/installment invoices intentionally remain scheduler-driven (no flow).
Booking built-ins now gate every outbound document on an explicit admin OK:
prepare_* drafts the doc, the admin adjusts line items/terms, confirms the
"Review … before sending" gate, and only then does send_document fire. Added to
booking_full (contract + invoice) and booking_simple (invoice); seed versions
bumped so the disabled built-ins self-heal.
Migrated the remaining time- and event-driven triggers into the engine, all
additive / best-effort / fail-closed (no behaviour change when the flag is off):
- gallery.published (event creation)
- gallery.expiring + gallery.expired (expiration checker, alongside the email)
- quote.sent (was queued but never emitted — gap closed)
- contract.sent + contract.signed (sent, fully-signed via counter-sign or wet upload)
- customer.created (direct add + invitation accept)
- invoice.overdue (status→overdue flip, deduped per invoice)
Editor trigger list extended to match. Tests assert the review gates wire
confirm→send on both booking flows.
The workflow/dunning suites boot the full core-migration set in beforeAll via
bootCrmDb. In isolation that's ~1.3s, but under full-suite parallel load on a
small CI runner it can exceed Jest's 5s default, timing out beforeAll and
failing every test in the file (the CI flake). Match the existing pattern used
by the other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill)
and set jest.setTimeout(30000) on workflowEngine, workflowRoutes and
invoiceDunning.
Three more editable built-in flows, seeded disabled like the dunning ladder:
- booking_full: quote.accepted → prepare/send contract → admin "signed?" gate
→ create event → wait to event date → prepare/send invoice
- booking_simple: the no-contract path (quote.accepted → event → invoice)
- pre_event_email: customer reminder + admin heads-up, fired daysBefore the
event date
The booking document actions stay stubs (observable skipped steps) until the
booking cutover. pre_event_email uses the already-wired send_email action, so
it is functional once enabled — backed by a new scheduler emitter
(emitDueEventReminders) that fires event.date_approaching for events entering a
flow's lead window, deduped per event. Refactors the boot seeder to a built-in
registry so each flow self-heals on its own SEED_VERSION.
Engine testRun() walks the whole graph immediately: waits pass through,
gates auto-confirm, side-effecting actions short-circuit to {dryRun, would}
so no real emails go out. POST /admin/workflows/:id/test-run returns the
run status + per-node step log. Admin list gets a flask button that opens
a result modal with an optional entity id (e.g. invoice) for conditions.
Closes the crash-safety gap: a run left in running/pending by a crash had
nothing to resume it (the scheduler only wakes 'waiting'). Adds a heartbeat
(workflow_runs.updated_at, stamped on every node advance + start/resume) and a
recoverStaleRuns() sweep that re-enters runs whose heartbeat has gone stale
(>10 min) from their persisted node. Runs on the scheduler tick AND the boot
tick, so a restart catches anything stranded during downtime.
Re-entry is at-least-once (the current node may re-execute) — loop counters +
the late-fee math are idempotent, so the only residual risk is a duplicate
reminder email. An attempts counter (migration 145, cap 5) marks a run failed
instead of recovering a node that reliably crashes the process (crash-loop
backstop). Flag-gated. Tests: orphan-resume + crash-loop cap.
Covers the tax-sensitive bits the dunning rework added (previously untested):
flat vs percent fee, the VAT toggle applying the org rate AND no-op'ing when
the org has no VAT rate, per-reminder accumulation (2nd=1x / 3rd=2x), the
invoice total staying immutable while the fee is tracked, and the 3-reminder
cap. Exports the fee resolvers + applyReminder for testing; PDF render stubbed
(flaky in CI, verified manually). 6/6 pass.
Corrected dunning model (Mara): a Mahnung is a reminder LETTER showing the new
total (original + Mahngebühr), NOT a separate invoice and NOT a mutation of the
issued invoice.
- The invoice PDF no longer shows the fee (buildInvoiceRenderContext reports
lateFeeAmountMinor 0) and is NEVER re-rendered by a reminder — it stays
immutable (§14/§11).
- applyReminder now: tracks the fee as dunning state on the row (gross
late_fee_amount_minor + new late_fee_vat_minor for the VAT portion, migration
144), renders a separate MAHNUNG PDF (pdfService 'mahnung' kind — reuses the
invoice layout: same lines + Mahngebühr row + new total, 'Mahnung' title, no
QR), stored under storage/business-docs/mahnung/, and attaches BOTH the
unchanged original invoice + the Mahnung to the reminder email.
- Fee resolvers split into net + VAT-rate (toggle + org-rate gated); a gross
wrapper feeds the payment-check preview. en + de PDF title.
Outstanding/collections still read late_fee_amount_minor (now dunning state).
P3 (tax-report/Banana booking of the Mahngebühr VAT) stays Treuhänder-gated.
Syntax + 17/17 workflow/invoice tests green.
NOTE: the Mahnung PDF render path isn't unit-tested (PDF rendering is flaky in
the test env) — eyeball on the dev box: fire a level-2 reminder, confirm the
Mahnung PDF shows the new total and the original invoice PDF is unchanged.
Mahngebühr VAT differs by country (CH: liable; DE/AT: not), so it's now a
toggle (crm_invoices_late_fee_vat_enabled, seeded into migration 143 in place
since it isn't deployed yet — no compensation migration). When on, VAT is
added on top of the net fee at the org's default rate
(business_profile.vat_rate_default). Gated so it's a NO-OP when the org doesn't
charge VAT (default rate 0/unset) — i.e. enabling the toggle on a non-VAT org
adds nothing, as required. Settings UI: a self-documenting checkbox.
The fee is treated as net + VAT-on-top; the tax-report VAT breakdown for the
fee is part of the deferred dunning-document rework. tsc 0, build green,
9/9 workflow tests.
New escalate_to_collections action: when the 3-reminder loop ends still
unpaid, consolidate ONE email to the admin — customer data, outstanding
(invoice + late fees − paid), and the invoice PDF attached — ready to forward
to an Inkasso agency / for Betreibung. Internal mail, sent immediately; does
NOT touch the invoice. New invoice_collections_handoff email template (en +
native de, seeded by the boot self-heal). Wired into the built-in dunning
flow: loop 'exit' → collections → end (seed v4, re-seeds the disabled
built-in). Selectable + labelled in the canvas editor. Tests 9/9, tsc 0,
build green.
- Late fee can now be a FLAT amount OR a PERCENTAGE of the invoice gross
(crm_invoices_late_fee_type/_percent, migration 143; defaults preserve the
current flat behaviour).
- Fee is charged from the 2nd reminder onward and accumulates per fee-bearing
reminder (2nd = 1×, 3rd = 2×), computed from the level so re-applying a level
never stacks. New resolvePerReminderFeeMinor() shared by applyReminder + the
payment-check fee preview.
- Reminder ladder extended to 3 levels (caps raised in sendReminder +
recordPaymentCheckAction); the built-in dunning flow now loops 3× (seed v3,
re-seeds the disabled built-in on boot).
- Settings UI: flat/percent toggle + percent field, and a prominent AGB
callout — a late fee is only enforceable if the concrete amount is stated in
the terms (Mara's wording), 'verify with your Treuhänder'. en + native de.
The fee math is examples-only / Treuhänder-verify; issued invoices stay
immutable (the fee is tracked in late_fee_amount_minor, not folded into the
original total). Tests 17/17, tsc 0, build green.
A 'Text' toggle in the editor toolbar swaps the canvas for the whole flow as
pretty JSON ({name, trigger_type, enabled, nodes, edges}). Copy it to share or
hand to an LLM, or paste a flow and 'Load into editor' (validates parse + one
trigger; backend re-validates on Save). Imported nodes land at 0,0 — one
'Clean up layout' click arranges them. en + native de.
Adds a one-click tidy that re-lays the graph top-to-bottom with dagre
(@dagrejs/dagre) and fits the view — handles the loop-back cycle by breaking
it internally. en + de string.
Addresses editor UX feedback:
- Dark mode: pass React Flow's colorMode (admin isDark) so the zoom/lock
controls, minimap and selection render dark instead of white-on-black.
- Readable nodes: show a human label derived from type+config (e.g. 'Invoice
paid?', 'Send payment-check email', 'Repeat ≤ 2×', 'Wait until due date')
instead of the raw node_key, and label each output handle on the node
(yes/no, confirm/deny, loop/exit) so branching is self-explanatory.
- Structured config: replace the raw-JSON textarea with a per-node form
(NodeConfigPanel) — dropdowns for action/condition/recipient/operator,
typed wait/loop/gate fields, live-applied; an 'Advanced (JSON)' expander
remains for anything the form doesn't cover.
- en + native de strings for all of it.
tsc 0 errors, build green.
On Postgres, knex .insert() without .returning() resolves to [], so ins[0]
was undefined → the child workflow_nodes inserts hit a NOT NULL violation and
the whole transaction rolled back. Result on PG: migration + tables present
but zero rows — the seeded dunning flow never persisted, and the 'New
workflow' button would 500. SQLite returns the row id, so the test harness
masked it.
Add .returning('id') and normalise the {id} (pg) vs bare-id (sqlite) shapes
(same pattern as the crmDb harness) in both the built-in seed and the admin
create route. Tests stay green on SQLite (17).
Makes the built-in dunning flow a faithful replacement for the hardcoded
reminder ladder instead of a disabled representation:
- queue_payment_check action delegates to invoiceService.queuePaymentCheckEmail,
so the proven confirm + reminder_level + Mahngebühr state machine
(recordPaymentCheckAction) stays the single source of truth — the workflow
only decides WHEN the payment-check email (the gate) fires.
- runScheduledTasks now SKIPS the hardcoded reminder batches when workflows is
on AND the invoice_dunning built-in is enabled, so the two never double-send.
- The built-in graph is re-authored to the delegation model (wait→due, grace,
loop: check-paid → payment-check → wait-gap), dropping the redundant gate +
generic reminder emails. A SEED_VERSION re-seeds the disabled, never-activated
built-in on boot but never touches an enabled/edited one.
Tests: delegation graph shape, re-seed-when-stale, enabled-protection (9 engine
+ 8 route = 17 passing).
Adds the workflows.* block (list, approvals inbox, canvas editor) to en.json
and de.json so the Workflows UI no longer renders English inline fallbacks
under a German UI. DE authored natively.
Adds the admin Workflows surface (top-level nav, gated by the workflows
flag + workflows.view): a list page (enable toggle, delete, new), a
pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react)
canvas editor — palette to add nodes, drag handle→handle to connect
(branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a
side-panel JSON config editor, and save (writes a new version). Routes +
sidebar entry + workflows.service. Build + tsc clean.
NOTE: the workflow page strings render via inline English fallbacks; DE
translations for the workflows.* block are still pending native review.
Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due,
grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder
loop with re-check, final notice) keyed on builtin_key='invoice_dunning',
sized from the reminder_first/second_days settings. Seeded DISABLED and
is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler
ladder still runs) — enabling it pre-cutover would double-send, so the
engine cutover is a deliberate follow-up. Idempotent (preserves admin edits).
Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape
+ idempotency.
GET/POST/PUT/PATCH/DELETE /api/admin/workflows with graph read/write (PUT
writes a fresh node/edge set under version+1 and bumps workflows.version so
in-flight runs keep their pinned version). Run-history (/:id/runs,
/runs/:runId/steps) and the pending-approval inbox (GET /approvals,
POST /approvals/:id/:action → actById) round it out. Gated by the workflows
flag + RBAC (view for reads, manage for writes); built-in flows refuse
delete; graph validated (exactly one trigger, unique keys, edges reference
known nodes). Route tests cover CRUD, validation, version bump, toggle,
inbox, and the 403 permission gate.
gate_setup action creates a workflow_approvals row (single-use token stored
as SHA-256 hash) and emails the admin confirm/deny links immediately
(internal mail, no business-hours floor). actByToken / actById finalize the
approval and resume the run down the matching confirm/deny edge; both are
idempotent (a second click → 'already recorded') and respect expiry. Public
GET /api/public/workflow-approvals/:token/:action returns a small HTML
confirmation page (clickable from email, single-use so prefetch can't
double-act). listPending backs the webview inbox (wired in the CRUD phase).
Test covers gate→approval→email→token-confirm→resume + idempotency.
Wires the workflow event bus into the hot paths, AFTER each commit:
- invoiceService.sendInvoice → invoice.sent (idempotent per invoice id, so
overdue re-sends don't double-fire)
- invoiceService.markPaid → invoice.paid, only on the transition into paid
(transaction result captured so the emit runs post-commit, never rolling
back a recorded payment)
- quoteService.recordResponse / adminAcceptQuote / adminDeclineQuote →
quote.accepted / quote.declined via a shared emitQuoteEvent helper that
resolves the customer email for downstream send_email actions
All emits are best-effort and fail closed when the workflows flag is off.
Existing invoice/quote integration tests still green.
Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business-
hours floor via queueEmail's respectBusinessHours) and the invoice_paid
condition (paid_at / status / cumulative paid_amount). Registers the
prepare_quote/contract/event/gallery/invoice + send_document + reserve_date
document actions as recognized-but-not-yet-wired (record an observable
skipped step rather than crashing a flow). index.js side-effect-imports the
handlers. Tests cover the customer-mail routing + the invoice_paid logic.
Adds engine.runDueWaits() — polls waiting runs whose wake_at has passed and
resumes the ones parked on a wait node (gate timeouts handled later by the
approvals layer). Flag-gated (fails closed when workflows is off). Wired into
the existing hourly invoiceScheduler tick in its own try/catch so a workflow
failure never suppresses the invoice/reminder jobs. Test covers not-due vs
elapsed resume.
Graph executor that walks nodes/edges per run: trigger, condition/branch
(registered conditions → yes/no edge), bounded loop (counter in context +
maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate
(status=waiting; resumed via confirm/deny edge), action/webhook (registered
handlers). emitWorkflowEvent creates one idempotent run per matching enabled
workflow (unique dedup_key) and fails CLOSED if the flag system is
unavailable; never throws into callers (safe to call after commit). Every
node records a workflow_run_steps row. Registry seeds primitive
conditions (always/never/expr) + actions (noop/log/set_context). Integration
test covers loop+wait resume, gate confirm, and dedup.
Adds the workflow engine's graph data model — workflows, workflow_nodes,
workflow_edges (versioned so in-flight runs keep their version),
workflow_runs (status/current_node/context, wake_at for the scheduler,
unique dedup_key for idempotency), workflow_run_steps (per-node audit),
and workflow_approvals (hashed email confirm/deny token + webview inbox).
Seeds workflows.view / workflows.manage and grants them to super_admin +
admin. Loose-FK integers per the whatsapp_queue/expenses convention;
idempotent hasTable guards + reversible down().
New opt-in 'workflows' master flag (default off) across the backend
KNOWN_FLAGS/DEFAULT_FLAGS and the frontend FeatureKey union, context
defaults, and a new Automation section card in the Features tab. Gates
the upcoming Workflows admin area and the engine runtime. en/de i18n
added (DE native).
CI's frontend test job failed with "Failed to parse JSON file, invalid
JSON syntax found at position 163854" on de.json:3041. The German
description used „…" — the opening „ (U+201E) was correct, but the
closing was an ASCII " (U+0022) which the JSON parser treated as the
string terminator, leaving "-Abläufe..." as garbage outside the string.
Replace with the proper German closing quote " (U+201D). 84/84 vitest
suite now passes locally. End-to-end smoke against a dev backend with
migration 141 applied confirms the modal renders correctly on desktop
(centered card) + mobile (bottom slide-up) and the backend returns the
structured 403 on the 11th-click cap hit.
Also flagging adjacent: origin/beta has a pre-existing duplicate `Mail`
import in frontend/src/pages/admin/SettingsPage.tsx (lines 20 + 58 from
commit 69367b45) that breaks Vite dev's Babel parser but passes prod
esbuild — out of scope for this PR, separate fix needed.
Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).
## Schema (migration 141)
Two nullable columns on `event_feedback_settings`:
- `max_favorites_per_guest`
- `max_likes_per_guest`
null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.
## Backend
- `feedbackService.submitFeedback` cap check on the INSERT branch only.
Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
matches the exact same guest-key shape the existing duplicate-check
uses (guest_id when present, fallback to guest_identifier in simple
identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
service-return into a structured 403 with `code:
'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
`current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
optionally render a counter near the heart icon (UI extension TBD;
the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
per-field error messages.
## Frontend — the popup
New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.
Mobile-first responsive:
- `items-end sm:items-center` — slides up from the bottom on phones
(native action-sheet feel), centers on desktop (familiar modal).
- `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
desktop.
- `rounded-2xl sm:rounded-xl` — more rounded on phones for the
sheet feel.
- `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
and Android gesture bar.
- `z-[60]` — above the lightbox's z-50.
Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.
New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).
## Admin UI
`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.
## i18n
EN + DE for:
- Modal title + body (parameterized with `{{limit}}`)
- Counter pill (parameterized with `{{current}}` / `{{limit}}`)
- OK button label
- Admin field labels + hints + section header + grandfathering note
## Tests
**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
- null cap → unlimited (back-compat)
- 0 cap → unlimited (UI convenience)
- cap=10: rows 1-10 succeed, 11 returns limit_reached
- toggle-off frees a slot at the cap
- limit reduction grandfathers existing rows
- per-guest scope: guest A's cap doesn't affect guest B
- favorite cap doesn't block likes (per-type)
- like cap returns LIKE_LIMIT_REACHED-shaped payload
**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
- Non-axios errors → null
- Non-403 axios errors → null
- 403 with wrong code → null
- FAVORITE_LIMIT_REACHED parsed
- LIKE_LIMIT_REACHED parsed
- Falls back to code-implied type when feedback_type missing
- Missing numeric fields → 0 (not NaN)
All 15 pass. tsc --noEmit clean. eslint clean on changed files.
Closes#655.
CI runners hit Jest's default 5s `beforeAll` timeout on
slideshowPublic.test.js's bootCrmDb call (~5.4s observed vs ~2s local —
runner-to-runner I/O variance, not a regression). Same hook shape on
slideshowAdmin.test.js is one slow runner away from the same failure.
Raise both to 30s so this stops blocking unrelated PRs branched off beta.
Adjacent to #654 — not strictly part of that fix but the only blocker
between #656 and a green CI right now.
Reporter @Duecki1 hit "Incorrect Password" on byte-correct input from
Instagram's iOS/Android IAB. Backend bcrypt compare is fine — the
frontend was handing it a mangled byte sequence because the password
Input lacked the autocaps/autocorrect/spellcheck/autocomplete defenses
Instagram's WKWebView keyboard bridge needs (the standard `type="password"`
WebKit defaults that suppress autocaps get overridden inside the IAB).
Three layers of defense:
1. **Explicit input attributes** on the gallery password field —
`autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`,
`autoComplete="current-password"`. Stops iOS autocaps turning
`wedding2026` into `Wedding2026`, stops predictive-text rewrites,
nudges password managers to autofill the right credential rather
than the IAB's stale saved-password store.
2. **Silent `.trim()` on submit** — Android Instagram IAB's predictive
keyboard often appends a trailing space when the user taps the
submit button. Event-gallery passwords don't legitimately carry
leading/trailing whitespace (they're set by photographers, usually
generated short strings), so trimming here is safe.
3. **Instagram IAB detection banner** — `frontend/src/utils/inAppBrowser.ts`
detects the `Instagram` UA tag and surfaces a one-time advisory at
the top of the password card with the right platform-specific
"Open in external browser" instructions (⋯ menu copy for iOS,
⋮ for Android). Self-rescue path for users who hit it before we
can close every keyboard mangling vector.
Scope is strictly Instagram per #654. Facebook IAB (`FBAV`/`FBAN`)
behaves identically and would benefit, but expanding the matcher is
a separate scope decision — the detector + i18n shape leaves room for
it without further refactor.
EN + DE i18n for the banner; 8 vitest cases on `detectInAppBrowser`
(iOS / Android Instagram UAs, plain Safari / Chrome / desktop UAs,
case-insensitive match, word-boundary defense against substring
collisions, SSR-safety when `navigator` is undefined). Lint + tsc
clean; pre-push Playwright smoke still expected green.
Closes#654.
PR #646's review-round renumbered its slideshow migrations to 138 + 139
to slot in after PR #649's 137 (whatsapp_template_language). That now
collides with this PR's 138. Slide ours to 140 so all three land in
strict order: #649 (137) → #646 (138, 139) → this PR (140). Content
unchanged; pure rename + a one-line docstring tweak noting the slot.
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.
This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.
## Schema (migration 138)
Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.
## Processor
- `parseTemplateParams(raw)` — defensive parser; falls back to the
5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
slots in the listed order, computed via a small switch on slot key.
The password line still receives the locale-specific 🔒 label and
the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
the parsed array to `buildComponents` per message.
## Admin route
- GET surfaces `template_params` as the parsed array (default 5-slot
when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
before persisting, so the stored value is always the canonical
sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
admin's test message matches their configured slot shape — a
reporter who configures 2 slots gets a 2-parameter test send, not
the legacy 5-parameter payload.
## UI
- `WhatsAppTab` gets a checkbox + up/down list under the Template
language field. Each slot shows its current `{{N}}` position when
checked, an em-dash when unchecked. Live preview below the list:
"Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
human-readable names.
## Tests
- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
covering: parseTemplateParams sanitization (unknown keys, duplicates,
non-strings, malformed JSON, all-invalid fallback, pre-parsed array
acceptance) and buildComponents shape (reporter's 2-slot case,
reorder, empty list, locale-specific password label, password
sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.
## Migration numbering
Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.
## Honest caveat
Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
PR #649 takes migration 137 (add_whatsapp_template_language). Renumber the
slideshow migrations to slot in after it:
- 137_add_slideshow_share.js -> 138_add_slideshow_share.js
- 138_add_slideshow_styling.js -> 139_add_slideshow_styling.js
and update the slideshow migration-number references in comments/types. No
content change — both are additive + addColumnIfNotExists-guarded, so re-running
under the new filename on an already-migrated DB is a safe no-op.
Each /state poll fired ~10 getAppSetting reads to resolve the watermark/fit;
a leaked link x N tabs amplified that linearly (review concern 2). Add a
5s-TTL cached bundle (utils/slideshowGlobals) for the global slideshow_* +
branding-logo settings, invalidated on PUT /admin/settings/slideshow so admin
live-edit stays instant. slideshowSettings now does ~2 reads per poll (event
row + photo count) on a cache hit. Also documents the frontend
optimistic-default nit.
The slideshow JWT reuses type:'gallery', so verifyGalleryAccess accepts it on
every gallery route — a leaked projector link could download (single/all/
selected), upload (when allow_user_uploads), or post feedback for up to ~12h,
beyond its display-only contract. Add a `denySlideshowToken` middleware (403
when req.accessLevel==='slideshow') after verifyGalleryAccess on those 5 routes.
The photo-display routes (/photos, photo/thumbnail/preview/hero) stay open — the
kiosk needs them. +4 tests mint a real slideshow JWT and assert 403. Docs note
that Regenerate/Disable isn't instant revocation (~12h) and the feature flag is
the hard cut-off.
Reporter @Rekoo-PS hit three independent gaps trying to deliver an
Arabic Meta template. Bundled here because they fan out from the same
root cause (no first-class language config on the WhatsApp tab) and the
review surfaces are tightly coupled.
**1. Test send hardcoded `en_US` (`adminWhatsapp.js:141`).** Smoking gun
for "I can't make it work" — Meta returned template_not_found_in_language
(132001) on every test send for non-English templates, no matter what
else the admin configured. Replaced with `config.template_language ||
'en_US'`.
**2. No `template_language` field on `whatsapp_configs`.** The only
priors were per-message `data.language` (always null from our callers in
`adminEvents.js:854,1188`) and `app_settings.general_default_language`
(the *system UI* language, not the *template's* language registered with
Meta). Migration 137 adds the column; GET + PUT surface it; the
processor uses it as the highest-priority default when message_data
doesn't override.
Resolution order in `whatsappProcessor.processWhatsAppQueue` is now:
1. message_data.language (per-event override — caller path TBD)
2. config.template_language (admin-pinned template language)
3. app_settings.general_default_language (system fallback)
4. en_US (hardcoded last resort)
**3. `LANGUAGE_MAP` + `PASSWORD_LABELS` didn't cover Arabic.** Added
`ar` (Meta's single-code form per RFC; no region variant). For any
language we don't enumerate (e.g. Turkish `tr_TR`, Chinese `zh_CN`,
Hebrew `he_IL`), `resolveLanguageCode` now pass-throughs valid-shape
codes (lowercase-language + optional underscore + uppercase-region) and
forwards them to Meta as-is. If they don't match a registered template
Meta returns 132001, which the test route already surfaces back to the
admin via `error.message` — fail-loud, no silent fallback.
Validation:
- Unit smoke on `resolveLanguageCode` across 18 representative inputs
(in-map, pass-through, canonicalization, rejection) — all behaviours
correct.
- Lint clean on all 7 changed files.
- Frontend `tsc --noEmit` clean.
- Migration `node -c` syntax-checked; additive + `hasColumn`-guarded so
re-running is safe.
Frontend: free-text input on the WhatsApp tab with EN + DE i18n.
Pointing at Meta's supported-languages docs via the hint text — Meta's
list grows; a hardcoded dropdown would rot.
Closes#647.
Reporter @aemisrogers nailed the root cause: same #317 class of bug as
logoUrl. None of `GALLERY_THEME_PRESETS` (`theme.types.ts:125`) include
`customCss` in their `config` object, so any path that REPLACES
`currentTheme` with `preset.config` (or with a sparse `newTheme` that
came from `preset.config` upstream) silently dropped `customCss` from
React state. The persisted value in `theme_config` stayed correct (the
public gallery still rendered it), but the admin textarea showed
empty on reload — admin-UI display drift, not data loss.
Three surgical fixes, mirroring the #317 logoUrl pattern:
1. `BrandingPage.tsx` `handleThemeChange` — `customCss: newTheme.customCss
?? currentTheme.customCss` alongside the existing `logoUrl` fallback.
Closes the propagation hole where the customizer's `handlePresetSelect`
fires `onChange(preset.config)` (no customCss) and the parent wipes
it from currentTheme.
2. `BrandingPage.tsx` `handlePresetChange` — preserve `customCss` from
prev/currentTheme on preset switch, same shape as the existing
`logoUrl: prev.logoUrl` preservation. Touches both the `setCurrentTheme`
and the preview-mode `setTheme` paths.
3. `ThemeCustomizerEnhanced.tsx` `handlePresetSelect` — remove the
`setCustomCss('')` that wiped the local textarea state on preset
pick. The previous comment ("Clear custom CSS when selecting a preset")
described the original intent but produced data drift across the
preset round-trip. The sibling `ThemeCustomizer.tsx` already never
cleared it; this aligns the two.
Verified against `v3.44.0` and `origin/beta`: identical code on both
branches, so the bug exists on stable + beta. Lint + tsc clean on the
two changed files.
Closes#645.
- docs/live-slideshow.md: full feature guide (enable, generate link, run on a
projector, global Settings -> Slideshow defaults, per-event overrides, how
live updates work, security notes).
- README: Live Slideshow bullet under Key Features, a Live Events use case, and
a Documentation quick link.
25 tests over two files, using the integration test-DB helper (real sqlite,
all migrations):
- slideshowPublic: resolveSlideshow guards (feature-flag kill-switch -> 404,
unknown/null token, expired/draft/archived), the watermark cascade (global
look + per-event on/off + source->URL resolution + "null when no logo"),
image fit, and /session minting (token + cookie). Regression-guards the
app_settings reads (vs the nonexistent `settings` table bug).
- slideshowAdmin: generate/disable/regenerate, PATCH display + watermark mode,
feature-flag 403, no-token 401, and PUT /admin/settings/slideshow validation
+ clamping. Both generate and PATCH assert success despite events having no
`updated_at` column (the original 500).
The slideshow display preset (transition / interval / speed / color filter) was
set PER EVENT TYPE in the Edit Event Type dialog. Replace it with a single
picpeak-wide default in Settings -> Slideshow ("Default style for new
slideshows"). New events seed their show_* columns from this global preset
(was: from the event type's slideshow_preset); the per-event override is
unchanged.
- Removed event_types.slideshow_preset usage everywhere (EventTypeModal section,
eventTypes.service types, eventTypeService whitelist, adminEventTypes
validators/POST). The DB column from migration 138 is left inert.
- Global preset stored in app_settings (slideshow_interval_ms/transition/
transition_ms/colorfilter), saved via PUT /admin/settings/slideshow.
- adminEvents create-seeding now reads the global preset (getAppSetting) instead
of the event type.
- en/de: presetTitle + presetHint.
object-fit was hardcoded to 'cover', which crops portrait photos heavily. Add a
global `slideshow_fit` setting (Settings -> Slideshow): 'cover' fills + crops,
'contain' shows the whole image with black bars (no crop). Default 'cover'
(unchanged). Stored in app_settings (no migration), resolved server-side into
the slideshow settings + /state poll so a running projector picks it up live.
Disabling the `slideshow` feature previously only hid the admin UI — the public
/show/:token route ignored the flag, so already-minted links kept working. Gate
resolveSlideshow on isFeatureEnabled('slideshow') so every /session and /state
404s when the feature is off: clicking Start shows "link not active" and a
running projector stops within one /state poll. Belt-and-braces: also gate the
admin generate + settings PATCH endpoints with requireFeatureFlag so links can't
be minted/changed while off (disable stays open so stale tokens can be cleared).
The watermark look (logo / position / opacity / style) was configurable in three
places — the global Settings tab, the per-event-type preset, and the per-event
card. Consolidate it to ONE: the global Settings -> Slideshow tab. Per-event and
per-event-type now carry only the watermark MODE (inherit / on / off) — the
override structure — and render with the global look.
- New global "Size (% of screen)" control (slideshow_watermark_size, vmin-based)
so the logo can be scaled; resolved server-side into the watermark payload and
applied to the kiosk <img>.
- Backend slideshowSettings resolves the whole look from app_settings always;
per-event show_watermark only toggles enabled. adminEvents PATCH + type-preset
seeding no longer accept/seed per-event look fields; unused enums removed.
- Frontend SlideshowStyle drops the look fields (mode only); SlideshowStyleFields
watermark section is a single mode select with a "configured under Settings"
hint; SlideshowSettingsCard + Event type cleaned up.
- en/de: watermarkSizeLabel + watermarkModeHint.
(events.show_watermark_{source,position,opacity,style} columns from migration
138 are left in place but inert — the look is global now.)
- New `slideshow` feature flag (backend KNOWN_FLAGS/DEFAULT_FLAGS, frontend
FeatureKey + context default, a toggle card under Settings -> Features -> Core).
Default off; strictly opt-in.
- Move the global watermark defaults off the Event Types page into a dedicated
Settings -> Slideshow tab (new SlideshowSettingsPage), shown only when the flag
is on.
- Gate the per-event Live Slideshow card and the per-event-type preset section
behind the flag too (and stop writing a type preset when it's off).
- en/de strings for the feature card + settings tab.
The flash overlay had no base opacity and the keyframe animation has fill-mode
none, so after the first dip it reverted to opacity 1 and stayed opaque between
slides — hiding the image, then briefly revealing it on each advance. Set base
opacity 0, and swap the image at the flash peak so the cut stays hidden.
The slide <img> used maxWidth/maxHeight:100% with no width/height, so it
rendered at the photo's intrinsic size (e.g. the 1920px preview) and never
scaled up to the projector, leaving black bars all around. Pin the image to
100% x 100% and use object-fit: cover so it fills the whole page.
slideshowSettings used settingsService.getSetting, which queries db('settings')
- a table that does not exist in this app (globals live in app_settings). Every
GET /gallery/:slug/show/:token/session and /state therefore threw and returned
500 INTERNAL_ERROR once a valid token resolved. Switch to getAppSetting
(utils/appSettings), which reads app_settings where the slideshow_watermark_*
and branding_* values are actually written.
Log the failing request (status + body) to the console and show the backend
error message in the toast instead of a generic "Error", so failures are
diagnosable without server log access.
The events table has no updated_at column (only created_at, and no migration
adds one), so the slideshow generate/disable/settings endpoints 500'd with
'column "updated_at" does not exist'. Write only the show_* columns, and guard
the settings PATCH against an empty update.
Adds the slideshow.* block (transitions, color filters, watermark mode/style/
source, global defaults) and eventTypes.form.slideshowPreset labels in English
and German.
- per-event Live Slideshow card on the event detail page: generate/copy/
regenerate/disable the share link + live style (transition, timing, color
filter, watermark).
- shared SlideshowStyleFields, reused by the per-event card and the per-event-
type preset section in the Edit Event Type modal.
- global watermark default card on the Event Types page (Settings -> slideshow).
- WatermarkSourcePicker: visible logo tiles with previews (light logo / dark-mode
logo / favicon / event logo) instead of a blind dropdown.
- watermark mode tri-state (inherit/on/off) + white-vs-original style.
- supporting service methods + Event/EventType types.
- /gallery/:slug/show/:token route + SlideshowPage: splash -> fullscreen kiosk,
crossfade/cut/slide/kenburns/dip-to-white/dip-to-black transitions, color
filters, white/original logo watermark overlay, contain/letterbox, cursor
auto-hide, quiet-append of new uploads, live settings poll, and decode-ahead
preload (first slide decoded before playback) so transitions do not struggle.
- slideshow.service for session/state + shared style types.
- public GET /gallery/:slug/show/:token/session (validates token, mints a
slideshow-scoped gallery JWT + sets the per-slug cookie so <img> requests
authorize) and /state (cheap settings + photo-count poll). Reuse /photos for
the list; skip the view-log for slideshow access so the kiosk does not pollute
visitor analytics.
- admin slideshow link generate/disable + live style PATCH on events.
- event-type slideshow_preset whitelisted in CRUD; create-event seeds the new
event's show_* columns from the type preset.
- global watermark defaults via PUT /admin/settings/slideshow; watermark cascade
(global default -> per-event override) resolving the light/dark/favicon/event
logo url.
Code-scanning Trivy alerts on the open beta (PR #641). Of the 10 open
alerts, 6 are stale (lockfile already past the fix) or live in
floating-tag base images (`nginx:1.28-alpine`, `node:22-alpine`) which
auto-update on the next CI rebuild — no code change needed for those.
The 3 actually present in the current `backend/package-lock.json`:
- `qs 6.15.0 → 6.15.2` (CVE-2026-8723, alert #266). Bump override from
`>=6.14.2` to `>=6.15.2`.
- `brace-expansion 5.0.5 → 5.0.6` (CVE-2026-45149, alert #264). Bump
override from `>=5.0.5` to `>=5.0.6`.
- `uuid 8.3.2` transitively via `node-cron@3.0.3` (CVE-2026-41907,
alert #265). Add top-level `uuid: ^11.1.1` override so node-cron's
nested resolution collapses into our root uuid version. node-cron
uses only `uuid.v4()` — API-stable across v8 → v11. Verified the
scheduler still constructs tasks under the override.
Lockfile regenerated; net -9 lines (one fewer uuid copy).
Stale alerts that will close on next code-scan rebuild:
- #205 postcss (frontend lockfile already at 8.5.14)
- #221 i18next-http-backend (backend lockfile already at 3.0.6)
Auto-resolved on next image rebuild (no Dockerfile change — floating
tags):
- #267 nginx (frontend `nginx:1.28-alpine`)
- #223 ip-address, #156/#155 picomatch, #140 brace-expansion (all in
the npm CLI shipped inside `node:22-alpine`)
Refs: code-scanning alerts #264, #265, #266
Two security advisories landed against the open #641 branch — bundling
both because they touch independent surfaces and PR #641 is the next
beta ship vehicle.
**GHSA-9v4w-jrhx-g5wr (BOLA on /admin/photo-export/:eventId/*)** —
the three /:eventId-scoped routes in `adminPhotoExport.js` (filtered,
filter-summary, export) ran `adminAuth + requirePermission(...)` but
not `requireEventOwnership`, so any non-super-admin admin/editor with
photos.view (or photos.download) could enumerate + export the photos
of events created by other admins — leaking `original_filename`,
which routinely encodes client identity. Sibling `adminPhotos.js`
applies the middleware on every :eventId route; this file was the
single drift. Reporter: Wernerina.
**GHSA-wmjx-pc37-272r (NAT64 SSRF in `isPrivateIPv6`)** — the old
implementation did naive string-prefix checks (`startsWith('fc')`,
`startsWith('fe80')`) and had zero coverage for NAT64
(`64:ff9b::/96` per RFC 6052, `64:ff9b:1::/48` per RFC 8215). On
instances with NAT64/DNS64 egress, a webhook URL like
`http://[64:ff9b:1::a9fe:a9fe]/` translated through the gateway and
reached 169.254.169.254 — exfiltrating cloud metadata (IAM creds)
into `webhook_deliveries.response_body`. Rewrote `isPrivateIPv6` to
expand the address to its canonical 8-group form, block both NAT64
prefixes, decode embedded IPv4 from IPv4-mapped (`::ffff:0:0/96`) and
deprecated IPv4-compatible (`::/96`) forms and re-check via
`isPrivateIPv4`, and fail closed on any parse failure. Reporter:
tonghuaroot.
Added 34 unit tests covering: both NAT64 prefixes in hex + mixed
dotted-quad notation, IPv4-mapped IPv6 hex + mixed, deprecated
::IPv4 form, legacy fc00::/fd00::/fe80::/::1/:: cases stay blocked,
and public IPv6 (Google/Cloudflare/Google IPv6) negative controls
stay allowed.
Refs: GHSA-9v4w-jrhx-g5wr, GHSA-wmjx-pc37-272r
i18n audit caught one straggler — the eye-icon toggle on the access-token
input had a bare `aria-label={showToken ? 'Hide' : 'Show'}` that wouldn't
translate for screen readers on non-English locales. Switched to
`t('common.hide')` / `t('common.show')`; added the matching `common.show`
key in EN + DE (common.hide already existed).
The two remaining `placeholder=` literals in the WhatsApp tab are sample
ID strings (`123456789012345`, `gallery_ready`, `+49123456789`) — those
are identifier/value examples, not translatable English.
Other PR-touched UI surfaces passed the audit clean: 30 new i18n keys
across categories (5), settings.whatsapp (16), settings.features.whatsapp
(2), feedback (3), and the activity-log + bell entries (4) all exist in
both EN and DE.
Surfaced while exercising Part D (WhatsApp) end-to-end. Navigating to
Settings → WhatsApp triggered React error #310 ("Rendered more hooks
than during the previous render"). Root cause is pre-existing: the
SettingsPage redirect-to-visible-tab `useEffect` lived AFTER the
`if (isLoading) return <Loading />` early return, so on the
isLoading=true→false transition the hook count grew by one and React's
rules-of-hooks invariant blew up.
Move the effect above the early return so the hook count is stable
across renders. While here, switch the gating logic from "is the key in
the currently-visible nav list" (which the bundle couldn't reference
yet because the nav array is built lower down) to a small lookup keyed
by activeTab → matching dependency flag. That's an equivalent decision
for the four tabs we already gated (crm, contracts, reminderTemplates,
accounting) plus the new whatsapp tab.
Add `flagsLoading` from the FeatureFlags context to the deps so the
snap-back only fires once the server's actual flag values have arrived.
Without this, the initial render with the placeholder DEFAULT_FLAGS
would falsely snap away from any tab whose flag is "on" on the server
but absent from the placeholder.
Also add `whatsapp: false` to `DEFAULT_FLAGS` in FeatureFlagsContext
(was missing — TypeScript should have caught the Record<FeatureKey,
boolean> violation but the build pipeline didn't surface it). Without
this, `flags.whatsapp` is undefined on the placeholder, which had
secondary effects on tab visibility and the snap-back logic.
Verified via Chrome DevTools: Settings → WhatsApp now loads cleanly
with all 5 form fields, the saved config values prefilled, the Save
button, and the Send-test card.
Ports 8digit/picpeak@ed7943b as a TOGGLE rather than a replacement. The
current per-action shape (one row per favourite/like/rating/comment) stays
the default for backward compat with any external scripts consuming the
export; the new pivot shape (one row per (photo, guest_identifier) with
boolean is_favorited/is_liked + star_rating + comment) is opt-in via a
?shape=pivot query param and a dropdown in the admin feedback page.
Pivot wins for "which guests engaged with which photos" analysis in
Sheets / Excel pivot tables. Long wins for engagement timeline analysis
and re-importing into another tool. Different products, both valid.
### Backend
- `feedbackService.exportEventFeedbackPivoted(eventId)`: new method.
LEFT-of-Map approach, pure JS pivot so PG / SQLite behave identically.
Key is `(filename, guest_identifier)` — anonymous guests with no
identifier get a synthetic per-row key so two anonymous comments on the
same photo don't collapse. Comments: most recent wins (history dropped
in exchange for "current state" semantics). Hidden-by-moderator rows
excluded — the pivot represents what we want to surface, not the raw
event log.
- `adminFeedback.js` export route: accepts `?shape=pivot|long` (default
`long`). CSV filename now carries the shape (e.g.
`feedback-pivot-{id}.csv`) so repeated exports don't overwrite.
- `convertToCSV` helper in `adminFeedback.js` gains the three escaping
improvements that 8digit's commit also shipped: booleans → `yes`/`no`,
null/undefined → empty, escape strings containing newlines (\n/\r) as
well as commas/quotes. Comments with line breaks were silently breaking
CSV row counts before this. Improvements are pure wins regardless of
shape; archives' own `convertToCSV` copy left untouched (separate
surface, no behaviour drift risk).
### Frontend
- `feedback.service.ts` `exportEventFeedback()` gains optional `shape`
parameter, default 'long'.
- `EventFeedbackPage.tsx`: new shape dropdown next to the CSV / JSON
buttons (defaults to 'long'). Selected shape flows through to the API
request AND the downloaded filename.
### i18n
3 new EN + DE entries (`feedback.exportShapeLabel`,
`feedback.exportShapeLong`, `feedback.exportShapePivot`).
### Notes
- Pivot shape is **per-guest current state**, not history. A guest who
rated a photo, then changed their mind and removed the rating, would
show the final state in the pivot but BOTH actions in the long form.
Acceptable trade-off: pivot users care about the snapshot, long users
want the trail.
- `latest_at` column in pivot gives a "most recent activity" timestamp
per row, useful for sorting/filtering recent engagement.
### Test plan
- [x] Backend syntax + TS check + lint clean (no new warnings; existing
`catch (error)` warning was pre-existing)
- [ ] Manual: feedback page → select Per-guest (pivot) → Export CSV →
verify one row per (filename, guest) with is_favorited='yes'/'no',
latest_at column populated
- [ ] Manual: long shape default still produces the same per-action
output as before (no regression for existing consumers)
- [ ] Manual: comment containing a newline → pivot CSV escapes correctly,
row count matches data length + 1 header
- [ ] Manual: archive a published event with feedback → archive's
`feedback_data.csv` still uses the long shape (archive surface
unchanged on purpose)
Ports filpgame/picpeak's WhatsApp integration with substantial adaptation
to fit our codebase patterns. Deliver the gallery-ready notification via
Meta Graph API in addition to (or instead of) email — useful where the
customer base expects WhatsApp by default. Strictly opt-in behind the new
`whatsapp` feature flag.
### Backend
- **Migration 136** (`whatsapp_configs` + `whatsapp_queue`). Loose-FK on
`event_id` matching our `inbound_documents` / `expenses` pattern (NOT
filpgame's hard FK — deleting an event shouldn't RESTRICT on stale queue
rows). Composite index on `(status, retry_count, created_at)` covers the
poll path.
- **`whatsappService.js`**: thin Meta Graph client. Meta API version bumped
v19 → v20 (filpgame's v19 deprecates Q3 2026); configurable via
`WHATSAPP_META_API_VERSION` env var. Timeout dropped 10s → 8s for
processor budget. Errors surface the Meta `error.code` so the processor
can tell retryable from permanent.
- **`whatsappProcessor.js`**: queue processor polling every 30s (configurable
via `WHATSAPP_QUEUE_POLL_MS`), 10 messages per cycle, 3 retries before
marking `failed`. Default language sourced from
`app_settings.general_default_language` (matches our email-language
resolution pattern); replaces filpgame's hardcoded `pt_BR` fallback.
Falls back to `en_US` if nothing is configured. No-ops gracefully when
the `whatsapp` flag is off, the config row is missing, or the access
token isn't set.
- **`adminWhatsapp.js`**: three routes (GET/PUT config, POST test). Gated
by `requireFeatureFlag('whatsapp')` so operators who haven't enabled it
can't see the surface. Access token masked as `'********'` on GET;
masked values silently preserve the stored token on PUT. Enabling with
no Phone Number ID, template name, or token (and none stored) fails at
the validator.
- **Two hook points** in `adminEvents.js`:
- **Create-and-publish-in-one-step**: queues immediately after the
`gallery_created` email when `!isDraft && customerPhone &&
waConfig.enabled`. Password from `req.body` is still in scope.
- **Publish-from-draft** (`POST /:id/publish`): queues with the password
the admin re-typed via PR #627's `PublishGalleryDialog`. When no
password was typed (legacy API consumers without dialog), passes empty
string so the password line renders blank rather than leaking the
`(set at creation)` sentinel.
- **`server.js`**: starts `whatsappQueueProcessor` at boot. Non-fatal if it
fails to start (logged as warning).
- **`feature_flags`**: new `whatsapp` flag in `KNOWN_FLAGS` and
`DEFAULT_FLAGS` (default false).
### Frontend
- **`featureFlags.service.ts`**: `'whatsapp'` added to `FeatureKey` union.
- **`FeaturesTab.tsx`**: WhatsApp card in the Communication section
(between Incoming mail and Messaging). Smartphone icon, "new" status,
sidebar-hidden (no sidebar entry — config lives under Settings).
- **`whatsapp.service.ts`** (new): typed client for the three admin routes.
- **`WhatsAppTab.tsx`** (new): Settings tab. Form for Phone Number ID,
WABA ID, access token (masked toggle), template name, and enabled flag.
Separate card below for a static test send. Token masking matches the
server's `'********'` sentinel — admin can edit other fields without
re-entering the token.
- **`SettingsPage.tsx`**: WhatsApp tab nav item gated on `flags.whatsapp`
(so it shows only when the feature is enabled); render block wires
`<WhatsAppTab />`.
### i18n
22 new EN + 22 new DE entries covering the Settings tab form, the
Features-tab card, plus `admin.activities.whatsapp_config_updated` +
`admin.notificationMessages.whatsappConfigUpdated` for the bell /
dashboard surfaces from PR #637.
### Deliberately NOT included
- filpgame's **password-encryption-at-rest** layer
(`password_encrypted`/`password_iv`/`password_key_version` columns).
Our publish-from-draft password recovery uses the admin re-type flow
from #627 (PublishGalleryDialog) — no plaintext at rest.
### Setup notes for operators
1. Create a Meta Business Account + WhatsApp Business App.
2. Register a phone number and obtain `phone_number_id` + `waba_id`.
3. Create a system-user access token (long-lived recommended).
4. Submit a message template for approval. The default `gallery_ready`
expects 5 body parameters: customer name, event name, gallery link,
password line, expiry date.
5. Enable the `whatsapp` feature flag.
6. Enter credentials under Settings → WhatsApp, send a test, then enable
delivery.
### Test plan
- [x] Backend `node -c` on all new/changed files clean
- [x] `tsc --noEmit` on frontend clean
- [x] Backend dev container restart picks up new files, /health OK
- [ ] Manual: enable `whatsapp` flag → Settings → WhatsApp tab appears
- [ ] Manual: save config with masked-only token (existing token preserved)
- [ ] Manual: enable=true without phone_number_id rejected at PUT
- [ ] Manual: enable=true without stored or new token rejected at PUT
- [ ] Manual: create-and-publish event with customer_phone → queue row
inserts with message_type='gallery_created'
- [ ] Manual: publish-from-draft via PublishGalleryDialog with password →
queue row uses the admin-typed password in the {{4}} line
- [ ] Manual: test send to a real phone with valid Meta config + approved
template → Meta returns messages[0].id, toast shows the id
- [ ] Manual: bell renders "WhatsApp configuration updated" in DE when
the config_updated activity fires (via PR #637 smart default)
Ports 8digit/picpeak@88bfde1 — replaces `window.confirm()` with a styled,
themed, accessible in-app modal. Usage:
const confirm = useConfirm();
const ok = await confirm({
title: 'Delete event?',
message: 'This will permanently remove the gallery and all photos.',
variant: 'danger',
confirmLabel: 'Delete',
});
if (ok) doDelete();
Three variants: 'primary' (default, no icon), 'danger' (red AlertCircle +
red confirm button), 'warning' (amber AlertTriangle). Keyboard support:
Escape cancels, Enter confirms (unless focus is in an input/textarea/select
so an open form doesn't get hijacked), backdrop click cancels. Cancel button
is focused by default — a stray Enter cannot accidentally confirm a
destructive action.
Wraps at App.tsx level, inside GlobalThemeProvider so the modal respects
the theme tokens, above the toast container so a confirm appearing under a
toast still gets the click. Provider exports through components/common
alongside the rest of the shared primitives.
This PR only lands the primitive. Existing window.confirm() call-sites are
left untouched — sweeping them is follow-up work that can land in any
cadence (each sweep is one component, no architectural risk). Existing
structured-input flows (PublishGalleryDialog, DuplicateEventDialog,
PasswordResetModal, etc.) stay as-is — they collect data, not yes/no.
No new i18n entries — uses common.cancel / common.confirm / common.close
which already exist in EN + DE.
### Test plan
- [x] tsc --noEmit clean
- [x] eslint clean on changed files
- [ ] Manual: pick any existing window.confirm() site (e.g. EventDetailsPage
delete button), swap to useConfirm(), verify the modal renders with
theme tokens, Escape cancels, Enter confirms, backdrop click cancels,
focus lands on Cancel
- [ ] Manual: variant='danger' renders red confirm button + AlertCircle icon
- [ ] Manual: open the dialog from inside another modal (e.g. a settings
panel) — z-[9999] keeps the confirm on top of any other overlay
Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.
Defaults to true so categories created before migration 135 keep working
without admin intervention.
Credit: 8digit/picpeak@928164b + @751ec75.
### Backend
- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
- `GET /:slug/photos` returns `allow_downloads` per category AND
`category_allow_downloads` per photo.
- `GET /:slug/download/:photoId` returns 403 when the photo's category
disables downloads.
- `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
`whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
The null check covers pre-migration-135 rows during the upgrade window.
- `POST /:slug/download-selected` same filter pattern.
### Frontend
- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
Green DownloadCloud icon when downloads are on, plain Download icon when
off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.
No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.
### Test plan
- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
(filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
→ downloads still work (defaults true via fallback)
Two related backup-integrity fixes from 8digit's fork (issue #640 items
#3 + #4), bundled because they touch the same two files and ship better
together than apart.
### Stream-extract restore for >2 GiB archives
`adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP
into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap,
so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and
since the frontend `onError` toast is the generic "Something went wrong",
the cause stays invisible. Real-world wedding archives routinely cross
2 GiB; affected restores have likely been silent failures.
Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk
as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape:
```js
const zip = new StreamZip.async({ file: archivePath });
const entries = Object.values(await zip.entries());
await zip.extract(null, eventDir);
await zip.close();
```
Re-import logic (photos, categories, sizes) unchanged; only field rename
`entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6.
### Preserve `original_filename` via photos manifest
Archive → restore round-trip currently loses `original_filename` (the
post-#508 column tracking the camera-side name) because the gallery
filenames are renamed on upload and can't be derived from the extracted
files. This matters now that the Lightroom export (#623) depends on
`original_filename` — a restored event lost that signal.
- **`archiveService.js`**: writes `photos_manifest.json` into the archive
containing per-photo `{filename, original_filename, type, uploaded_at,
category_name}`. Non-fatal: a manifest write failure falls through to
legacy behaviour (filename used as original_filename, same as before).
- **`adminArchives.js`**: reads the manifest on restore, builds a
`Map<filename → manifest>`, and assigns
`original_filename = manifest?.original_filename || filename`.
Archives produced before this lands have no manifest — restore logs a
one-shot notice and falls back to filename, preserving backward compat.
Credit: 8digit/picpeak@eb018aa.
### Deps
- Removed `adm-zip ^0.5.16`
- Added `node-stream-zip ^1.15.0`
### What's NOT in this PR
8digit's commit also fixed the production compose healthcheck (`curl`
isn't in our Alpine image); that's already been addressed upstream in
the meantime. The frontend `onError` swallow on the restore toast is a
separate small follow-up.
### Test plan
- [x] `node -c` on both files clean
- [x] `node-stream-zip` async API verified at load time
- [ ] Manual: archive a multi-GB event → restore → confirm photos
re-import with original_filename preserved
- [ ] Manual: restore an archive produced before this lands → confirm
fallback to filename works (no manifest path crashes)
- [ ] Manual: confirm the new photos_manifest.json is inside the
generated archive (`unzip -l <archive>.zip | grep manifest`)
Continuing the activity-type i18n sweep from this PR: three settings
tabs still had hardcoded English strings (or referenced i18n keys that
didn't exist in either locale).
EventsTab (Settings → Event Creation):
- defaultFeedbackEnabled + defaultFeedbackEnabledHelp were referenced
by the component but missing from both locales. The inline-default
English text leaked through to German users.
ApiTokensTab (Settings → API Tokens):
- "Preview" table-header column was a bare string literal; now wraps
through t('settings.apiTokens.preview').
- confirmRevoke called t() with a backtick template-literal default
("Revoke \"${token.name}\"…"). The interpolation happened at the
default-string level, so the actual translated string never received
the name and shipped without it. Switched to the i18next {{name}}
parameter pattern with the matching value in en+de.
WebhooksTab (Settings → Webhooks):
- Half the tab was still hardcoded English. Wired everything through
t(): toast messages (createError, updateError, deletedToast,
deleteError, copied, copyFailed), Just-Created Secret card buttons
(Copy, Dismiss), form placeholders (name, URL, template), advanced
toggle label, filter and template help paragraphs, the filterError
setter, all six table headers, the eventsSubscribed count (with
proper {{count}} pluralisation), the status badge (Active/Disabled),
the active/inactive title tooltips, the Deliveries link, the Delete
button, and the delete-confirm dialog (proper {{name}} interpolation
instead of the broken template-literal-in-default-string pattern).
Added 34 new key/value pairs to each locale; counts now symmetric at
events=28, apiTokens=23, webhooks=43 in both EN and DE.
DE wording authored natively; tone matches the existing maintainer-
voice style.
The admin notification bell and dashboard "Recent Activities" panel were
showing raw snake_case keys ("event_published") or the generic
"Systemaktivität: <type>" fallback for ~65 activity types — most of them
from the CRM and Accounting modules added since #555. Users with German
locale saw the gap most visibly because the English placeholder leaked
through.
Three pieces:
1. notifications.service.ts — smart `default:` branch. Instead of falling
straight to the systemActivity template, derive the camelCase i18n key
from the snake_case type, try resolving `admin.notificationMessages.<camelCase>`
directly with the full metadata spread as params, and only drop to the
legacy template when no specific translation exists. This means every
future activity type just needs an i18n entry — no per-type switch
case to add.
2. en.json + de.json — added 65 missing `admin.notificationMessages.*`
bell entries and 58 missing `admin.activities.*` dashboard entries
across both locales. Covers Contracts (13), Quotes (7), Invoices /
Storno (12), Monthly billing (5), Expenses (4), Hours (5), Incoming
invoices (6), Customers (1), Admin user mgmt (3), and 9 misc /
legacy types (bulk_archive_completed, email_resent, email_queue_flushed,
email_template_created, event_duplicated, feedback_deleted,
feedback_moderated, feedback_settings_updated, word_filter_added).
Both locales finish symmetrical (149 activities / 136 notifications
each, vs. 91 / 71 before).
3. admin.service.ts `formatActivityMessage` messages dict — added the
same 58 English-only entries as a last-resort fallback for the
dashboard when i18n itself fails to load. Keeps the surface
resilient against bundle-load issues.
Metadata field names in the new translations match what the backend
writes via `logActivity()` — `{{contractNumber}}`, `{{quoteNumber}}`,
`{{invoiceNumber}}`, `{{username}}`, `{{template_key}}`,
`{{source_event_name}}`, `{{word}}` — verified against the call sites
in contractService, quoteService, invoiceService, userManagementService,
expenseService, adminEvents, adminEmail, adminFeedback.
DE wording authored natively; tone matches the existing terse,
maintainer-voice style of the rest of the file.
LineItemsTable's live preview did `Math.round(subtotal * vatRate) / 100` where
subtotal is in major units and vatRate is a fraction (0.081) — rounding to whole
units before the /100 divided the VAT by 100 (CHF 0.63 instead of 63.18). Add the
missing *100 inside the round so it rounds to cents. Backend computeTotals + the
PDF + the tax report were always correct; this preview-only bug just surfaced now
that new invoices seed a non-zero default VAT code instead of 0%.
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface):
- taxReportService: income totals excluded only `status='cancelled'`, never
`kind='storno'`. A Storno (status='sent', amounts stored negative) netted into
the totals on top of the already-excluded cancelled original → double-subtract,
so a cancel-and-reissue read as 0 income instead of the reissued amount.
Now exclude storno rows from grandTotal*/byRate (kept visible in the row list).
Regression test reproduces the real cancel→storno→reissue 3-row flow.
- customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)`
on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the
invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass).
Normalise via the Date branch like every other date read.
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
longer auto-classifies every supplier — incl. the admin's own domestic one —
as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
so they aren't billable to an anonymized account. (NB: picpeak has no hard
customer delete — erase anonymizes in place — so the orphan/404 premise can't
occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
(the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
/hourly + profileFields.title/savedToast).
- Box 1 "Default rates": mileage, daily allowance, hourly rate, require-proof.
Hints now make the cost-vs-billing split explicit (daily allowance = expense,
hourly = billing fallback).
- Box 2 retitled "VAT": registration, reclaim, default invoice VAT code, and
the VAT label (moved out of its own card).
- Drop the third card (AccountingProfileFields deleted); the two Save buttons
become one — it persists both the app_settings and the two business_profile
fields (VAT label + hourly rate) together.
- Rename "Per-diem" → "Daily allowance" (EN) for clarity; German keeps the
established "Spesenpauschale".
VAT supplier-country reclaim default:
- Migration 134 adds inbound_documents.supplier_country.
- categorizeInbound auto-derives tax_treatment via resolveTaxTreatment:
explicit treatment wins; else country in the reclaim list → domestic,
outside it → foreign_vat_non_reclaimable, unknown → domestic. Consumes the
previously-stored-but-unused accounting_vat_reclaim_countries.
- Triage modal gains a Supplier country dropdown (saved via updateInbound).
+5 unit tests for resolveTaxTreatment.
Configurable default output VAT code for new invoices:
- New accounting_default_output_vat_code setting (PUT wired; getSettings/type).
- Settings → Accounting dropdown to pick it.
- Invoice + quote editors seed their VAT picker (rate + code) from it on a
blank new document — skipping edits/conversions, never clobbering a touched
value. New docs no longer silently start at 0%.
i18n en + de.
logActivity writes via the global db; called inside a db.transaction it
deadlocks against the held write lock on a SQLite-backed install (a second
write connection blocks). Stage the audit info inside each transaction and
fire it AFTER commit in createEntry / updateEntry / deleteEntry /
billUnbilledEntries — same fix already applied to expenseService. Return
shapes unchanged. (The monthly/billing paths still route through
createInvoice, whose own internal logActivity remains the shared root
limitation — tracked in feedback_sqlite_global_write_in_transaction.)
Invoice VAT config (codes + label) and the hourly rate now live under
Settings → Accounting, so an install with Invoices must have Accounting
available.
- applyDependencyRules (backend adminFeatureFlags.js + frontend
FeatureFlagsContext.tsx): bills on → accounting on, before the
accounting→children rule so the sub-features keep their own state.
- Migration 133 corrects existing installs: set the STORED accounting=true
where bills is on. requireFeatureFlag('accounting') reads the raw row, so
without this an upgraded install (invoices on, accounting off) would show
the tab but 403 its endpoints. Idempotent; only flips on; no down.
- Features tab: the Accounting card shows locked-on (disabled + hint) while
Invoices is enabled.
Also includes the i18n keys (en/de) for the VAT/financial settings move.
- Remove the orphaned "Default VAT rate %" from Business profile; the rates
are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
is now code-only — options are exactly the Accounting output codes, no
free-text custom rate. Off-list legacy values on existing invoices are
preserved as a read-only "(not configured)" option so issued documents
aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
AccountingProfileFields card; storage stays on business_profile, own save).
Wire vat_label onto the PDF VAT-line label via the issuer block (covers
invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
preserved). Add a moved-note callout. Strip the moved fields from the
Business-profile save so it can't clobber an Accounting-tab edit.
- Add backend/__tests__/integration/incomingInvoiceRebill.test.js (8 tests):
disposition state machine, per-event PENDING pool, passthrough-no-markup,
unwindBilledLine recompute, INVOICE_LOCKED on an issued invoice, and
re-categorisation transitions. The invoice-MINTING paths can't run inside an
outer transaction on SQLite (createInvoice's sequence claim deadlocks on the
held write lock) — covered by buildInboundLineItem unit tests + discountLineItems
instead; documented in the test.
- Move logActivity out of the categorize/rebill/bundle transactions. It writes
via the global db; inside a transaction a second write connection deadlocks on
a SQLite-backed install (also affected SQLite-prod, not just tests).
- Fix bill-editor vat_code reload: transformInvoice (adminInvoices.js) dropped
vatCode, so the editor fell back to rate-matching and lost a custom-rate code
on edit. Now returns vatCode: i.vat_code.
- Rewrite docs/accounting-inbound-invoices.md to the current implementation
(IR-vs-Expenses split, re-categorise + unwind, cadence-aware re-bill / pending
pool, passthrough-at-cost, migrations 122-132, rasterised preview, tax/ledger/VAT).
- Add a per-disposition info line under the Disposition dropdown so re-bill
vs pass-through vs company expense is clear in-context (en + de).
- Markup is a re-bill concept only: the control now renders solely for
rebill, and a pass-through always bills at cost. Enforced server-side too
(categorizeInbound applies markup only when disposition === 'rebill').
- Clarify "Book to" with a hint — it attributes the supplier cost to an
event in the tax report / ledger export, separate from who you re-bill to.
Address three incoming-invoice issues:
1. Re-categorization: a categorized invoice can now be changed again (e.g.
passthrough → company expense). New "Re-categorize" button pre-fills the
triage modal from the existing disposition/customer/markup/note.
categorizeInbound is re-runnable — it unwinds any prior re-bill line
(removes the invoice line + recomputes totals) before applying the new
disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an
already-issued invoice.
2. Note field: new `note` column (migration 132 — 126 is already on beta)
captured in triage and shown in the read-only view.
3. Re-bill like hours: rebill/passthrough now persist customer_account_id.
Per-event customers accumulate as PENDING items, surfaced in a new
"Pending re-bills" card and bundled into one invoice via "Bill these"
(mirrors unbilled-hours billing). Monthly/manual customers keep
auto-consolidating onto their running draft. Passthrough (durchlaufend)
can now also attach to a customer with optional markup.
Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and
en/de translations (other locales fall back to English defaults).
Follow-up to #623. The Lightroom TXT export now shows the filename list
in a modal with a "Copy to clipboard" button instead of triggering a
.txt file download — saves the "open file → select all → copy" dance
admins were doing anyway. CSV export takes the same path (paste straight
into Sheets / Excel).
The modal keeps a "Download as file" button so admins who want the file
(sharing with colleagues, archiving, post-processing tooling) aren't
worse off than before — fully additive.
XMP (ZIP archive) and JSON exports keep their direct download path. A
textarea preview is the wrong UI for a binary archive, and JSON is
structured tool input where the file form is the natural mode.
Implementation:
- ExportPreviewModal — readonly textarea, copy + download buttons,
monospace font for filename lists, click-to-select-all on the textarea
for browsers that block clipboard writes (older Safari, hardened
sandboxes — the catch falls through to a "select and copy manually"
toast instead of silent failure).
- photosService.exportPhotosAsText — same backend endpoint as
exportPhotos but resolves the blob.text() and returns
{ content, filename } instead of triggering a download. Preserves
the existing exportPhotos for the XMP / JSON paths.
- PhotoExportMenu — PREVIEW_FORMATS = ['txt', 'csv']; non-preview
formats keep the direct-download flow unchanged.
- EN + DE i18n entries.
No backend changes. No new endpoints. No breaking changes for callers
of photosService.exportPhotos.
Daniel asked for a way to re-use a good gallery configuration without
re-entering every setting. Two of his three suggested workflows are
covered by this PR; the third (per-event-type behaviour defaults) is
partially shipped already via event_types.theme_preset + theme_config
and is left as a follow-up if the duplicate workflow doesn't cover it.
Backend — POST /admin/events/:id/duplicate. Validates a new event_name
(required) + event_date (optional) + customer_name/email (optional);
copies branding (color_theme, css_template_id, header/hero/divider/anchor),
behaviour toggles (allow_downloads, watermark_*, allow_user_uploads,
require_password, etc.), photo_cap, welcome_message, default_photo_sort,
admin_email, and feedback settings + per-event photo categories. Mints a
fresh slug + share_token + random-placeholder password_hash (admin sets
the real one via the publish dialog shipped in #627). Recomputes
expires_at = new_event_date + (source.expires_at - source.event_date) so
the duplicate keeps the same active window; defaults to 30 days if either
source field was null. is_draft is always true.
Deliberately NOT carried over: photos, hero_photo_id, client_access
secrets, og_image_share opt-in, customer_phone, sent_at flags, archive
state, customer-account assignments.
Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog
pattern), wired into the Actions card on EventDetailsPage. Visible in
both draft and live mode since admins typically duplicate from a
published gallery. On success the page navigates to the new draft so the
admin can finish customising + publish.
I18n: EN + DE entries for the dialog + button label. Backend logs an
event_duplicated activity with the source event id/name so the trail is
auditable.
Frontend service: eventsService.duplicateEvent(eventId, data).
The README claimed 2GB RAM as the minimum, but two background-processor
worker loops × sharp.concurrency(2) means up to four libvips threads can
decode full-resolution images in parallel — peak RSS lands at 1.5GB+ on
a batch of 20MP+ photos. Add Postgres + Redis + Node baseline and one
heavy batch on a 2GB VPS OOM-kills the backend, surfacing as 503s on
thumbnails until restart:unless-stopped brings it back. Reported in #602,
filed as #628.
Three changes, smallest-surface-area each:
1. backgroundProcessor.js — on startup, when UPLOAD_PROCESSOR_CONCURRENCY
is NOT set and os.totalmem() reports < 3GB, default to 1 instead of 2
and log a one-shot warning naming the override env var. Explicit env-var
setters keep their value. os.totalmem() reports container memory under
cgroup v2 so this works in Docker / k8s as well as bare metal.
2. README.md — bumped the documented minimum from 2GB to 4GB, kept 2GB
only as a "Low-memory hosts" recipe pointing at UPLOAD_PROCESSOR_CONCURRENCY=1
with the throughput trade-off spelled out. Added the 503-on-OOM symptom
so the next reporter finds it via search.
3. docker-compose.production.yml — commented mem_limit / memswap_limit
example on the backend service. Off by default (don't surprise existing
deployments) but visible to operators thinking about shared/multi-tenant
hosts. restart:unless-stopped already on every service.
No code path for memory-aware runtime throttling (Luca's option 4) — out
of scope for a bug fix; tracked separately if #1-#3 don't close the case.
Previously, publishing a password-protected DRAFT gallery sent the
gallery_created email with the literal sentinel "(set at creation)",
which the email processor localised to "The password you set when
creating the gallery" / "Das bei der Erstellung der Galerie gesetzte
Passwort". Root cause: at draft creation only the bcrypt hash is stored
(no plaintext column, by design); the publish endpoint had nowhere to
pull the actual password from. Create-and-publish-in-one-step worked
because the plaintext is still in memory at email-queue time.
Fix: the Publish action now opens a small PublishGalleryDialog that
prompts the admin to (re-)type the gallery password. The publish
endpoint accepts an optional `password` body, re-hashes + writes
`password_hash` so the stored hash matches what was just emailed (admins
who mistype at creation get a self-healing publish flow), and puts the
plaintext into the gallery_password email field. When the publish call
is made without a password (API-only consumers), behaviour falls back
to the legacy sentinel — no breaking change.
The window.confirm() publish flow is gone; the dialog handles the no-
password case too (plain confirm + Publish button).
I18n: EN + DE entries for the dialog. Other locales fall through to
the EN defaults via the t() default-value pattern.
No schema changes. No plaintext at rest.
GalleryAuthContext cached the event in sessionStorage on first visit and
then SKIPPED the server fetch on returning visits (`if (!storedEvent)`),
so a guest who'd already opened the gallery would never see admin edits
to welcome_message / event_name / hero_logo / colour theme — sessionStorage
survives Cmd+Shift+R, so the only escape was closing the tab or wiping
site data manually.
The cached event is still shown above as an instant placeholder for
perceived perf, but the server fetch is no longer gated: on every mount
the fresh row overwrites both React state and the sessionStorage entry.
Cost is one extra /gallery/:slug/photos request per gallery navigation
when the session is already authenticated; benefit is admin edits
propagating on next page load for everyone.
When a gallery uses the 'hero' header_style AND the admin enables the
filter bar (search + sort), the search/sort row glued itself to the top
of the hero image. Root cause: HeroHeader carries a decorative `-mt-6`
on its outer div (so it can bleed flush against the page header when
nothing else is above), and that exactly cancelled the wrapper's `mt-6`
between PhotoFilterBar and PhotoGridWithLayouts.
Fix: when the filter bar is shown above a hero header, the grid wrapper
uses `mt-12` instead of `mt-6` so the hero's bleed leaves a 24px net gap
rather than zero. The no-filter-bar case keeps the original flush bleed.
Also tidied up: extract the filter-bar-shown predicate to a named const
so the two reads (conditional render + wrapper class) can't drift apart.
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom
search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's
filename search wants a comma-separated one-liner, and the gallery JPEGs may
correspond to RAW files in the catalog — so the search has to match on the
stem only.
The frontend now passes `separator: 'comma'` + `include_extension: false` for
the TXT format specifically. The backend gains an `include_extension` option
(defaulting to true so direct API consumers don't break), and the comma case
joins without a trailing space (the form Lightroom expects). Unit test pins
the Lightroom-mode output AND the backward-compatible default for any direct
API caller.
CSV / XMP / JSON exports are unchanged.
The scope <select> inherited `w-full` from the shared selectClassName, so it
stretched the whole row on its own line (the ledger-format select overrides it
with w-auto; this one didn't). Give it `w-auto min-w-[140px]` and wrap it in an
inline "Scope" label so the Report row reads compactly as
"Scope [Complete ▾] [Export CSV] [Export PDF]", consistent with the journal row.
- New "For Studios — CRM & Accounting (Beta)" subsection under Key Features
(quotes→contracts→invoices+Storno, hours/calendar, inbound supplier invoices +
expenses, tax report + Treuhänder/Banana export, VAT) and updated the Roadmap
beta-table row to "CRM & Accounting Module".
- Broadened the disclaimers section to CRM & Accounting and added a Tax/VAT
bullet: figures are guidance only + jurisdiction-specific (e.g. the LI 20%
Gewinnungskosten flat rate), and every operator must verify their own tax/VAT
regulations with their accountant/Treuhänder/tax authority before relying on
any figure or export.
- Updated the @Luca-Timo contributor entry with a concise CRM + accounting credit.
Closes the test gaps from the PR #622 work + the export-scope feature:
- export scope: scopeLedger/normalizeScope (exported via _internal) unit tests +
renderTaxReportCsv income/cost/all output assertions (income drops supplier
rows, cost drops invoice rows, filename gets the scope tag).
- isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint
failed" message, false for FK + nullish (the IMAP claim-first race detector).
- getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before
touching pdftoppm/disk (the per-file resource bound).
Adds a Complete / Income only / Cost only selector to the readable PDF + CSV
export (the on-screen report stays complete). Income-only emits just the
outgoing rows + the income summary line (+ the per-rate breakdown in the PDF);
cost-only emits the incoming-invoice + expense rows + the cost line and drops
the income-by-rate breakdown. Useful in Liechtenstein where, under the income
threshold, a flat 20% Gewinnungskosten deduction is sometimes better than actual
costs — handing the Treuhänder just the income (or just the cost) basis is
cleaner.
Backend: renderTaxReportPdf/Csv take a `scope` param (all|income|cost) that
filters report.ledger by row.type + the summary lines; the /pdf + /csv routes
accept & validate `?scope=`; filenames get an income_/cost_ tag. Frontend:
scope <select> beside the export buttons, threaded through buildQueryString.
i18n en/de. The 20% calculation itself is intentionally NOT in-app (applied by
the Treuhänder) per the scoping decision.
1. Remove the committed test artifact backend/storage/business-docs/quote/2026/
Q-2026-0001.pdf and gitignore backend/storage/business-docs/ so generated CRM
docs can't be committed again.
2. adminLedger + adminExpenses dropped their local requireFlag copies and now
import the shared (now cached) requireFeatureFlag middleware.
4. roundTripTest polls IMAP with ×1.5 backoff (cap 8s) instead of a flat 3s, so a
30s test takes ~5 SELECT/SEARCH locks not ~10 (some servers throttle).
Nit 3 (dashboard + events pages still on the gallery-theme vars, not dark-mode-
swapped) is left as a documented follow-up per the review.
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
override — instead of the per-customer column alone, via a shared
customerFeatureAllowed() helper. Admin disabling a feature globally is now
honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
registered" and hid the reclaim). Treat null as "not configured":
vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.
Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
Blocker 1 — CSV/Banana formula injection. Neither csvEscape (ledgerService) nor
the tax-report CSV escape nor the unquoted tab-separated Banana cell formatter
prefixed risky leading chars, so an admin-/sender-controlled cell beginning with
= + - @ TAB CR executes as a formula when the Treuhänder opens the export. New
shared util neutralizeSpreadsheetFormula() prepends a single quote; wired into
all three sinks (quoted CSV + unquoted Banana). Unit test pins one of each char.
Blocker 2 — IMAP intake double-ingest race. received_emails.message_id was
INDEX, not UNIQUE, and the poller ingested attachments BEFORE writing the audit
row, so a second replica / rolling-deploy overlap double-ingested the same mail.
Migration 128 makes message_id UNIQUE (nulls stay distinct); the intake now
CLAIMS the message row (status='processing') BEFORE ingesting — a concurrent
claim hits the unique constraint and skips cleanly (shared isUniqueViolation
helper). Stale 'processing' rows (worker crashed mid-ingest) are reclaimed after
10 min so no attachment is orphaned.
NOT done (deliberate): the suggested UNIQUE on inbound_documents.file_sha256 —
that column is a SOFT dedup key by design (manual re-uploads are kept as flagged
'duplicate' rows + duplicate_of_id for the Duplikat disposition); a unique index
would break that feature. The file race only yields an extra 'unsorted' row (a
data-quality nit, caught by the existing manual Duplikat backstop), not a
double-count. Rationale to be added to the PR reply.
Reworks the previous force-mode UX per the intended model:
- Branding page IS the global preset — keep presets, colors, fonts and style
fully visible. The only change under a force lock: hide the redundant
per-theme Color Mode picker (light/dark is the Force control), with a hint.
- Force only locks light/dark again: reverted applyForceColorMode to swap the
surface palette only — it no longer resets typography/style, so the branding
fonts/style always apply.
- Per-event GALLERY theme editors now receive the global force value and, when
a lock is active, hide the colour pickers AND the light/dark picker (a gallery
can't override the site-wide lock). Presets/fonts/layout stay. Force off →
everything returns.
Wiring: CreateEventPage + EventDetailsPage pass
forceColorMode={publicSettings?.branding_force_color_mode} (value only, no Force
control). Branding keeps both the value and the onForceColorModeChange handler,
which is how the component tells the two contexts apart. i18n en/de.
Follow-up to the force-mode change: hide ALL gallery theme customization while
a force light/dark lock is on, not only colors + typography. Theme presets,
gallery layout, header style, controls style, the colour pickers (incl. accent),
Typography & Style, CSS templates, the PDF-typography slot and event custom CSS
are all hidden — only the Force color mode control (with an explanatory note)
and the Reset/Apply actions remain. Accent brand colours still apply to the
gallery; their picker is just hidden while the lock is on. Turning the lock off
restores the full customizer. Note text + i18n (en/de) updated.
When a force light/dark lock is active it now means "use the clean standard
look": applyForceColorMode also resets typography & style (fonts, size, corner
radius, shadow, background pattern) to defaults — on top of the surface/text
palette it already swapped — so those settings genuinely don't apply while the
lock is on. Accent brand colours and the structural cards (header/controls/
gallery layout/hero divider) are preserved. Override-only: the saved theme keeps
the admin's custom values, so turning the lock off restores them.
In the theme customizer, when a force mode is active, hide the now-dead controls
to avoid confusion — the per-theme Color Mode picker, the Surfaces + Text colour
pickers, and the whole Typography & Style card — and show an explanatory note.
The Force picker itself and the Accent pickers stay visible. i18n en/de.
This pairs with the admin dark-mode fix: admin surfaces follow the `.dark` class
which AdminDarkModeContext drives from the force lock, so force is respected
end-to-end.
CRM + accounting admin surfaces read gallery-theme tokens — the
`text-theme`/`text-muted-theme` utility classes and raw `var(--color-surface|
text|surface-border|elevated)` inline styles — which `ThemeContext` writes as
inline `--color-*` on <html> for every route. Those inline vars beat the admin
`.dark` toggle, so e.g. the "Create passive customer" modal (#620) renders dark
while the admin is in light mode (and the reverse).
Repoint every CRM/accounting admin surface to Tailwind `dark:` classes so it
tracks the admin toggle deterministically:
- text-theme → text-neutral-900 dark:text-neutral-100; text-muted-theme →
text-neutral-500 dark:text-neutral-400 (across the CRM list/detail/editor
pages, hours, calendar, lineage, installments, CRM settings).
- modal/dropdown/chip/divider inline `var(--color-surface*)` → bg-white
dark:bg-neutral-900 / border-neutral-200 dark:border-neutral-700 etc.
(CustomerManagement + CustomerDetail modals = the #620 fix).
- toggle off-track surface-border → bg-neutral-300 dark:bg-neutral-600; brand
accent ON-state kept (var(--color-accent)).
- CalendarPage FullCalendar chrome: scope local --cal-* vars under .fc / .dark
.fc so the calendar follows the admin toggle (was reading gallery vars).
- PasswordResetModal bare neutrals + TaxReport Storno/Reissue badges gain dark
pairings.
Brand accent/primary tokens and the branded admin login are intentionally left
on the gallery theme. The same leak exists in non-CRM admin areas (dashboard,
events) — out of scope here.
A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:
- Hours logging: only createEntry checked the flag — edit/delete/bill and
the list/summary routes were permission-only. Gate all six
/hour-entries routes on the hoursLogging master so a disabled feature
can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
the list + quotes routes have. Also fixes the quotes-PDF gate, which
read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
neither the master nor a per-customer column.
Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
detail page gains the toggle; service/validator/serializer wired.
Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
to Accounting); add the missing `projects` so it mirrors the context
derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
The "VAT code by revenue rate" rows were hardcoded to the Swiss/LI rates
(8.1/2.6/3.8/0), so a code at any other rate (e.g. DE 19%/7%) had no row
to map. Derive the rows from the distinct rates of the OUTPUT VAT codes
instead — retype a code to a local rate and its row appears automatically;
remove the last code at a rate and the row drops. The CH/LI seeds are
unchanged and still produce the same four rows.
Frontend rateKey() mirrors backend ledgerService.rateKey so the saved map
keys keep matching the export-time lookup. Each rate's dropdown is scoped
to output codes at that rate. Empty state when no output codes exist.
app_settings has no created_at column (src/database/db.js defines only
setting_key/value/type + updated_at), so inserting one threw — which
broke saving any FIRST-TIME setting key. Existing keys took the UPDATE
path and worked, hiding the bug; it surfaced on the new VAT-registration
toggle + reclaim-countries keys ("Failed to save accounting settings").
Also fixes the same latent failure on the customer-surface settings route.
Consolidate all accounting configuration in one place. The Chart of
accounts (accounts table + category/default-account mappings) becomes a
self-contained ChartOfAccountsManager rendered in Settings → Accounting,
next to the VAT codes that already moved there. The /admin/accounting
section is now purely operational (Incoming invoices · Expenses · Tax).
The old /admin/accounting/ledger route redirects to the settings tab so
bookmarks keep working; the Tax page "Configure" link points there too.
ChartOfAccountsManager saves only the account keys (partial-merge safe,
same as VatCodesManager), so the two never revert each other's edits.
Move VAT-code CRUD and the rate→code / treatment→code maps off the
Chart-of-accounts page into a self-contained VatCodesManager rendered in
Settings → Accounting, so all VAT config lives in one place. CoA keeps
the accounts table, default/system accounts, and expense-category maps.
Both pages save disjoint key sets through the partial-merge updateSettings
(CoA → account keys only; VatCodesManager → ledger_vat_map +
ledger_output_vat_map only), so neither reverts the other's edits.
The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT
minus the RECLAIMABLE input VAT only (costs with tax_treatment
foreign_vat_non_reclaimable are excluded from the deduction). Registration reads
accounting_vat_registered; when unset it falls back to a behaviour-preserving
heuristic (charged output VAT this period ⇒ registered), so existing reports are
unchanged and non-VAT installs correctly show 0. loadCosts now tracks
reclaimableVat. Tests updated; 32 pass.
Adds the 'VAT registration & reclaim' section to Settings → Accounting: a
'VAT-registered' toggle (charge output + reclaim input VAT) and a multi-select
of countries whose input VAT is reclaimable (default domestic CH/LI). Wires
accounting.service + the backend keys added earlier (accounting_vat_registered,
accounting_vat_reclaim_countries). i18n en/de. The report VAT-payable math that
consumes these is the next slice.
Slice 2 + 1b:
- Bill editor: VAT-rate field → VatRateSelect dropdown (mirrors the quote
editor); snapshots vatCode on create + carries it from a source quote.
- getQuoteById + the invoice serializer now return vat_code, so re-editing a
saved document preserves the snapshot instead of falling back to the
rate→code map. Payload types (quotes + bills) carry vatCode.
72 tests pass; build green.
Slice 3a — replaces the free-typed VAT rate in the quote editor with a dropdown
of configured output VAT codes (+ 'Other (custom rate)'), reading the un-gated
/admin/vat-codes endpoint. Selecting a code sends vatCode → the backend snapshots
it (migration 130) and the export emits it. New VatRateSelect component + a
read-only vatCodes.service. Create flow snapshots correctly; loading a saved code
into the editor (serialization return) + the bill editor are the next slices.
Build green.
Slice 1 of the VAT consolidation backend:
- PUT /admin/settings/accounting accepts accounting_vat_registered (bool) +
accounting_vat_reclaim_countries (ISO-2 list); GET /:type already returns
them parsed, so no GET change needed.
- New read-only GET /api/admin/vat-codes (adminAuth, NOT accounting-gated) so
the invoice/quote editors can populate their VAT dropdown even when the
accounting layer is off. Management CRUD stays under /admin/ledger.
Wires the vat_code snapshot (migration 130) through the write paths: quote
create/update, the main invoice create, and the Storno carry-over (so a
cancellation exports the same code as the invoice it reverses). Guarded with
hasColumnCached; reads payload.vatCode (sent by the editor dropdown, coming in a
later slice — inert until then, falls back to the rate→code map). 72 tests pass.
First slice of the VAT-consolidation: migration 130 adds a nullable vat_code
snapshot column to quotes + invoices, and the Treuhänder export now prefers the
invoice's snapshotted code over the (mutable) rate→code map, so a historical
invoice's VatCode never changes when codes are re-mapped. Schema-drift guarded;
behaviour-neutral until the editors start writing the snapshot (next slices).
Part of: VAT registry → Settings→Accounting, invoice VAT dropdown, registration/
reclaim toggle.
Real Banana Income & Expense files name the category column 'Category', not
'ContraAccount' (which the doc listed but is a double-entry concept) — so the
income/expense account never landed and Banana warned 'ContraAccount column not
found'. Use 'Category'. VatCode stays (it only warns on a non-VAT-enabled file;
amounts are gross). Test updated.
The Date column imported empty into Banana because dateOnly() did
String(d).slice(0,10) — on Postgres the date columns come back as JS Date
objects, so that yields "Thu Jan 15" instead of "2026-01-15", which Banana
rejects. (SQLite returns strings, so the tests never caught it — the
pg-date-serialisation trap.)
- ledgerService.dateOnly + taxReportService CSV now format Date objects to
yyyy-mm-dd via local calendar parts (DATE columns are local-midnight).
- Regression test added with a real Date object (the existing tests all used
string dates).
The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.
Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
Description, Income, Expenses, ContraAccount (the income/expense account),
VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
both Banana variants. Labels relabelled: "Banana — double-entry" and
"Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.
Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
The /ledger/export route sent the file without a BOM, so Banana (and Excel)
decoded it as the local charset — the '·' description separator and any umlauts
imported as mojibake ('·'). Prepend the EF BB BF BOM like the tax-report CSV
route already does.
Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.
- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
with no quoting, .txt extension, text/plain content-type. generic + bexio stay
comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).
The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
The CSV rework (unified, typed ledger) replaced the 'Rechnung' column with
'Referenz' (+ a 'Typ' column) and dropped the separate cancelled 0/1 column in
favour of a localised '(Cancelled)' suffix on the Reference cell. Update the
two assertions in taxReportPdf.test.js accordingly. All 11 cases pass.
- Give all four export controls (CSV / PDF / format select / Accountant export)
a matching min-width so the two rows form a tidy right-aligned button grid
(CSV over format select, Export PDF over Accountant export).
- Replace the dashed sub-divider between the Report and Accounting journal
groups with a solid line so the separation reads clearly.
- Restructure the export area into two labelled groups: 'Report' (PDF/CSV,
for you) and 'Accounting journal' (for your accountant), each with a
one-line caption — instead of two unlabelled button rows.
- i18n: the English label was the German 'Treuhänder export' → now 'Accountant
export' (de stays 'Treuhänder-Export'); hint reworded.
- Feature flags: the journal export is an accounting-layer feature (needs the
Chart-of-accounts mapping), so gate it on the 'accounting' master — the
group only renders when accounting is on, and the backend /export route no
longer requires the 'taxReport' sub-flag (the router already requires accounting).
Build + node --check + JSON parse green.
The standalone 'Treuhänder export' tab duplicated the Tax page's period/
currency filters over the same data. Fold the collective-journal export into
the Tax page as a third export action (target-tool format picker: generic /
Banana / bexio), beside Export CSV/PDF, with a link to its Chart-of-accounts
config. Removes the Accounting sub-nav 'export' tab (old /export route now
redirects to the Tax page); keeps Chart of accounts as its own setup tab.
Deletes the now-orphaned LedgerExportPage.
Build + JSON parse green.
The summary card's top block (Total net/VAT/gross) is the outgoing-invoice
totals but had no section header, unlike the 'Income / costs' block below.
Add an 'Outgoing invoices' (de: 'Ausgangsrechnungen') header to match.
Replaces the separate revenue + costs tables with a single ledger across the
screen, CSV and PDF. Every row is typed (outgoing invoice / incoming invoice /
expense) and signed — outgoing positive, incoming + expenses negative — so
sorting by value runs income → costs and the column nets toward the Result.
- getTaxReport now returns a `ledger` array (signed, typed, date-sorted);
legacy rows/costs/summary kept for back-compat.
- Frontend: one sortable table (click Type/Date/Party/Net/VAT/Gross), coloured
type badges, cancelled rows greyed with lineage badges; Income/Costs/Result
summary box unchanged.
- CSV + PDF reworked to the same unified, signed layout; PDF totals show
Income / Costs (negative) / Result.
- i18n: en/de (frontend) + pdf-i18n (en/de real; fr/nl/pt/ru English-fallback,
flagged for native review).
Build + node --check + JSON parse green.
The tax-report cost query selected inbound_documents.description, but that
column only exists on the 'expenses' table — inbound_documents has none. On
Postgres this threw 'column inbound_documents.description does not exist',
so the whole cost side failed with 'Costs could not be loaded'.
Use inbound_documents.invoice_number (an existing column, same descriptor
ledgerService surfaces) as the cost-row label instead. Expense rows still
use their real expenses.description column.
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
The previous wrapper gated /admin/* on a /auth/session check and only showed
the panel when an admin session was detected. Two failures:
1. The session check effect depended on `isAdminRoute` (a boolean), so the
client-side login → dashboard navigation (both /admin/*) never re-ran it.
hasAdminSession stayed stale-false from the logged-out /admin/login render,
so a freshly logged-in admin landed on the maintenance screen anyway.
2. It also hid /admin/login itself (the catch-22).
Fix: the maintenance screen only blocks customer/gallery/public routes —
/admin/* is never blocked. The admin auth layer already handles access
(AdminLayout redirects a logged-out admin to /admin/login), so no session
probe is needed here. Removes the fragile /auth/session dependency entirely.
Backend skipPaths (/api/auth/admin/login + /api/auth/session) stays: login and
AdminAuthContext's token validation must still work during maintenance.
Turning on maintenance mode locked out every admin — including ones already
logged in — with no way back in from the browser. Two causes:
1. Backend (middleware/maintenance.js): the skipPaths allow-list pointed at
/api/admin/login and /api/admin/auth/login, but the real admin auth routes
live under /api/auth (POST /api/auth/admin/login, GET /api/auth/session).
So during maintenance both the login POST and the session check 503'd. The
503 on /auth/session made the frontend read every admin as logged-out, and
also tripped the axios interceptor that force-enables maintenance globally.
Fixed the allow-list to the actual endpoints.
2. Frontend (MaintenanceWrapper.tsx): the maintenance screen rendered over
every /admin/* route unless an admin session already existed — covering the
/admin/login page itself. A logged-out admin could never reach the form to
get a session (catch-22). /admin/login is now always allowed through.
With both: a logged-in admin keeps working (session check passes), and a
logged-out admin can reach /admin/login and sign back in, all while
maintenance mode correctly blocks customers.
Single-customer projects, but content is addable whenever ONE of its customers
is the project's customer (not only when the first lineage customer equals it):
- linkDealToProject: collect ALL customers across the deal's quote/contract/
invoice lineage and reject only when none is the project's customer. Mirrors
the events path, where a multi-customer event already attaches if any of its
customers matches. Adoption onto an empty project unchanged.
- assignDocument: a document carries one customer, so equality stays correct;
message aligned with the lineage check.
A project must stay tied to one customer. The quote/contract/hours attach
paths already rejected a foreign customer (equality on project.customer_account_id);
the two remaining holes are closed here:
- assignEvent: an event may only join a project that shares its customer. The
event's customer(s) come from event_customer_assignments; a customer-assigned
project rejects an event for a different customer (PROJECT_CUSTOMER_MISMATCH),
and an empty project ADOPTS a single-customer event's customer. This is why
a foreign-customer event could previously be attached.
- updateProject: re-labelling a project to a customer that conflicts with the
events/quotes/contracts it already holds is rejected (clearing to null is
still allowed), so the customer can't be swapped out from under existing
content.
Frontend: the cockpit attach-event action surfaces the translated mismatch
message; projects.error.customerMismatch reworded to read for both documents
and events (de + en).
Resolves the two blockers and the actionable concerns/nits from review.
Blockers (cross-customer leak):
- linkDealToProject: collect the deal's customer + events BEFORE any write,
then reject a cross-customer link with PROJECT_CUSTOMER_MISMATCH (422) before
re-pointing events/quotes/contracts or adopting a customer. The editors set
project_id via quoteService/contractService → linkDealToProject (not
assignDocument), so the guard lives at that chokepoint. Null-project adoption
("first deal wins") preserved as intended.
- assignDocument: boundary guard mirroring customerHoursService, defense-in-depth
ahead of the cascade.
- Frontend: translated PROJECT_CUSTOMER_MISMATCH (projects.error.customerMismatch,
de+en) wired into HoursSection + quote/contract editor onError (concern 5).
Concerns:
- 1: processEmailQueue gains an onlyId option; cockpit "send now" scopes the
flush to the single row so it can't force-retry other dead-lettered emails.
- 2: resendEmail re-stringifies email_data when PG returns a parsed object,
matching the canonical enqueue — no jsonb double-encode.
- 3: cockpit email feed scoped to the project's own document numbers (event_id
for gallery mails; email_data doc-number match for CRM mails) instead of the
recipient string — a shared inbox no longer leaks another customer's mail.
- 4: migration 117 backfill wrapped in a transaction (adds atomicity on SQLite,
where the runner does not wrap; PG already wraps the whole migration).
- 6: resend/cancel/retry/sendNow now logActivity uniformly (project_email_*),
adminId threaded from the route.
- 8: validator optional({ values: 'null' }) → optional({ nullable: true }).
- 9: pre-121 list valuation falls back to customer-scoped quotes so the list
isn't all-zero during the upgrade window.
Nits:
- milestone selection uses Array.at(-1); removed redundant in-loop require in
emailProcessor; clarifying comments for the list/detail perms split and the
count-vs-value (0 vs em-dash) convention.
#1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize +
markInboundPaid with the entered reference) instead of opening the pay dialog
and leaving it unpaid. Removed the PayModal chain.
#2 Cost side missed captured incoming invoices: the query required
currency='CHF', but email/upload invoices often have a null currency →
silently excluded. Now include null-currency rows (treated as the report
currency). Also replaced COALESCE(invoice_date, created_at) with a split
date filter (invoice_date BETWEEN, else created_at range) to avoid the
mixed date/timestamp comparison risk on Postgres. Same fix in the ledger
export (buildPostings).
en/de: categorizedPaidToast.
The Einnahmen-Ausgaben summary only rendered when costs existed, so a period
with no incoming invoices/expenses looked revenue-only. Now it shows whenever
the cost side loaded successfully (costs default to 0 → Result = Income), so
the income/result is always visible. Still hidden when the cost side errored
(the amber banner covers that case).
The frontend/backend image builds + pushes succeed, then the final
'exporting to GitHub Actions Cache' step intermittently fails with
'error writing layer blob: not_found' (a known flaky type=gha cache backend
issue), failing the whole job. Add ignore-error=true to every cache-to so a
cache-write hiccup can't break an otherwise-successful, already-pushed build.
The cost side is supplementary — it must never 500 the core revenue report.
getTaxReport now wraps loadCosts in try/catch: on failure it returns empty
costs + a costsError string and logs the real error. The tax page shows the
revenue report plus a non-fatal amber banner with the cost-side error message,
so the actual cause is visible in the UI instead of an opaque 500.
The #4 cost side used 'date(COALESCE(invoice_date, created_at)) BETWEEN ...'
and 'date(created_at) BETWEEN ...'. The mocked unit tests never execute the
SQL, so the Postgres failure (date()/COALESCE(date,timestamp)) slipped through
and surfaced as a 500 on the live tax report. Replaced with plain range
comparisons (col >= from AND col <= '<to> 23:59:59.999') — valid on both PG and
SQLite, inclusive of the whole end day. Same fix applied to ledgerService
buildPostings (the Treuhänder export would have 500'd identically).
#1 DocumentPreview renders the page pager for every PDF (disabled at the ends),
not only multi-page ones — so the control is visible on single-page invoices.
#2 Clicking a categorized (unpaid) invoice opens the Mark-paid dialog; new →
categorize, paid/declined/duplicate → view.
#3 A paid row no longer shows two 'Paid' chips — the front badge is the status,
and the right action becomes a quiet 'Mark unpaid' (revert).
#1 Row status reads 'Paid' (green) once supplierPaid — no longer the stale
'categorized' badge.
#2 Clicking a new (unsorted) invoice opens the Categorize modal; sorted ones
still open the read-only view.
#3 Triage gains a Payment reference field (persisted via updateInbound →
payment_reference).
#5 Triage has two actions: 'Save' (categorize only) and 'Save & mark paid'
(categorize, then chain into the mark-paid dialog with the reference
prefilled).
#4 (mark-paid PDF nav) was already present via DocumentPreview — no change.
en/de strings added.
Cause of 'not all received emails listed': the poller fetched {seen:false}
only, so any message already read in another client was never pulled or logged.
Now the poller scans a LOOKBACK_DAYS (90) window regardless of \Seen via a
cheap envelope-only pass, dedups by message-id against received_emails, and only
downloads + processes (fetchOne source) messages not yet logged — so the
Received tab is complete while each poll stays light. Marks processed messages
seen; re-checks the parsed message-id before insert.
#1 Incoming-invoice triage: 'Company expense' (eigener_aufwand) no longer shows
the event picker — it always books to the company (removed from
BOOKING_DISPOSITIONS, so categorize sends event_id null).
#2 Auto-refresh: AccountingInboxPage + ReceivedEmailsPanel poll every 30s
(refetchInterval) so background IMAP ingests appear without a manual reload.
#3 DocumentPreview defaults to the FIRST page (invoice header) for triage/view;
PayModal opts into the LAST page (Swiss QR-bill) via initialPage='last'.
Symptom: an emailed attachment landed in Incoming invoices but the message
never appeared under Received emails. The attachment is saved BEFORE the
received_emails insert, so any throw there left the audit row unwritten and
silently swallowed.
- coerce a malformed Date: header (Invalid Date) to now — it would otherwise
throw on the Postgres timestamp insert (most likely root cause)
- isolate each attachment in its own try so one bad file can't skip the audit
- truncate from_address to the column width; persist attachment errors + an
'error' status so partial failures are visible
- log loudly when the received_emails insert itself fails (no more silent loss)
Self-healing: the stuck message was never marked \Seen, so the next poll
re-processes it and writes the row.
- Root cause of the 502s: ImapFlow had no connect timeout, so a wrong host/port
(e.g. IMAP on an SMTP port) hung the request until the proxy returned 502 with
no message. Added connectionTimeout/greetingTimeout/socketTimeout + a hard
connectWithTimeout() race on every IMAP client (detect/test/roundtrip/poll).
- Error routes now return 422 with the underlying reason (was 502, which
collided with the proxy's own 502 and hid the message).
- New 'Check now' button + POST /incoming-config/poll runs the poller on demand
(respects the incomingMail flag) and reports disabled/unconfigured/busy or N
ingested — so 'nothing in Received' is diagnosable without waiting 60s.
- en/de strings
The round-trip recipient is imap_user (not hardcoded). Some hosts use a
non-email IMAP login — guard against silently sending to a bogus address:
return a clear 'recipient_not_email' error explaining to use a mailbox whose
username is its email, or test connection + manual send instead.
- emailIntakeService.roundTripTest(): sends a uniquely-tagged email through the
saved SMTP config to the IMAP mailbox (imap_user), then polls IMAP up to 30s
for that subject token; deletes the test message on arrival so it never hits
the accounting inbox. Returns {ok, seconds, recipient} or a typed reason.
- route POST /admin/email/incoming-config/roundtrip (email.send)
- IMAP card: 'Round-trip test' button beside 'Test connection' + Save; toast
reports recipient + delivery time. Distinct reasons mapped (smtp/imap
unconfigured, send_failed, not_received→504).
- en/de strings
- emailIntakeService.testConnection(): logs in, opens the configured folder,
reports message/unread counts (non-destructive). Accepts current form creds
so it works before saving; masked password falls back to stored.
- route POST /admin/email/incoming-config/test
- IMAP card: 'Test connection' button beside Save; toast shows folder + counts
- capitalize 'IMAP Host' label to match 'SMTP Host'
Note: incoming uses IMAP (receiving) vs outgoing SMTP (sending) — genuinely
different servers/credentials, hence the distinct field set (Folder; no From).
Reverts the auto-fill; drops the (993)/(143) from the Security option labels so
incoming behaves exactly like the outgoing SMTP card (plain SSL/TLS vs
STARTTLS, port set manually).
Selecting SSL/TLS sets port 993 and STARTTLS/none sets 143, so the port in
the dropdown label is no longer just decoration. A non-standard custom port
(anything other than 993/143/empty) is left untouched.
The IMAP card was restyled to match SMTP but didn't carry the required-field
markers. Aligned the required set (protocol differences kept):
- red asterisks on Host *, Port *, Username * (SMTP marks Host/Port/From-Email;
IMAP has no From-Email but always needs a login)
- client-side guard mirroring handleSaveSmtp (block save without host/port/user)
- backend POST /incoming-config now requires imap_user (the poller's
getImapConfig returns null without it)
- en/de requiredFields string
- IncomingMailConfigCard rebuilt to mirror the outgoing SMTP card: Card
padding=md, icon inputs (Server/User/Lock), password eye toggle, stacked
full-width fields, full-width primary Save button
- Folder is now a dropdown auto-populated by a 'Detect' button instead of a
free-text path: backend emailIntakeService.listFolders() lists IMAP
mailboxes (POST /admin/email/incoming-config/folders, accepts current form
creds, masked password falls back to stored); UI auto-selects the inbox
(special-use) folder
- en/de strings added
#1 Incoming invoices are re-viewable: extracted a reusable rasterised
DocumentPreview (last page = QR-bill), added a click-to-view ViewModal on
every row, and embedded the preview in the mark-paid dialog.
#2/#3 Expenses ledger:
- invoiced badge (links to the client invoice) + paid toggle (manual,
independent of invoiced)
- edit until invoiced (ExpenseFormModal now does create + edit; locked
rows show a Lock chip instead of edit/add-to-invoice)
- 'Add to invoice' action (re-bill via customer picker + markup) and a
'Mark paid' dialog
- service: Expense gains invoiced/billedInvoiceId/paid/paidAt fields +
invoiceExpense() and markExpensePaid()
en/de translations added.
Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case:
- taxReportService.getTaxReport now returns a cost side (loadCosts:
incoming invoices + internal expenses, company- or event-booked,
schema-guarded) plus a summary (income / costs / result, VAT payable)
- declined/duplicate costs excluded; re-billed costs kept (matching
re-bill revenue is counted, so the net is correct)
- CSV + PDF exports gain a Costs section and an income/costs/result
summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine —
flag for native review)
- frontend tax page renders the summary card, a costs table (company
vs event), and a 'verify with Treuhänder' disclaimer
- tax-report tests cover the cost aggregation + zeroed summary when the
accounting tables are absent; adminCrmAuth test enables the accounting
master flag the route now requires
fr/nl/pt/ru strings are machine-generated and need native review.
- transformExpense surfaces invoiced (billed_invoice_id), paid
(supplier_paid), paidAt, paymentMethod, customerAccountId
- updateExpense throws EXPENSE_LOCKED once invoiced (edit until then)
- rebillExpense mints a client invoice line + locks the expense
- markExpensePaid toggles manual paid state
- adminExpenses: POST /:id/invoice (rebill) + POST /:id/paid
- adminTaxReport now gated by accounting master + taxReport sub-flag
(independent of bills; tax export moved out of CRM into Accounting)
The app_settings table (per its migration schema) has no created_at/updated_at
columns — the canonical seed pattern (migration 103) inserts only
setting_key/setting_value/setting_type. Migration 127 wrongly added timestamps,
so the insert threw `SQLITE_ERROR: table app_settings has no column named
created_at` on every run of the migration suite. That broke the backend test
job (cascading through every suite that builds the schema) and the
Postgres-based fresh-install + schema-drift jobs.
Fix: drop the timestamp columns from the insert, matching migration 103.
Verified: full backend jest suite green (67 suites, 736 passed); migration
harness still green.
Frontend for the incoming-mail feature.
- Settings -> Email: an "Incoming mail (IMAP)" block under the outgoing SMTP
settings (same field shape: host/port/security/user/pass/folder), shown only
when the incomingMail flag is on (IncomingMailConfigCard, self-contained
load/save).
- A "Received emails" tab next to "Sent emails" (ReceivedEmailsPanel) listing
the received_emails log with from/subject/received/status + attachment count
and a link to the incoming-invoices inbox.
- `incomingMail` flag in the frontend (type + context default, standalone) +
a Communication-section Features card.
- email.service: getIncomingConfig / updateIncomingConfig / listReceived.
- i18n: settings.features.incomingMail, email.incoming, email.received (EN+DE).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
Adds a second mail config (incoming/IMAP) alongside the outgoing SMTP one, a
1-minute poller, and a received-emails log. Standalone `incomingMail` feature
flag (default off).
- deps: imapflow + mailparser (receive-side; picpeak only had nodemailer).
- migration 128: email_configs gains imap_* columns (same shape as smtp_*);
seed incomingMail flag; new received_emails audit table.
- emailIntakeService: polls the mailbox every 60s when the flag is on AND a
mailbox is configured (no-op otherwise); parses each unseen message
(mailparser flattens forwarded/nested attachments), drops PDF/JPEG/PNG into
the incoming-invoices inbox (inbound_documents, source='email'), logs each
message in received_emails (dedupe by message-id; duplicate attachments
caught by the existing SHA-256 guard), marks it \Seen.
- adminEmail: GET/POST /incoming-config (mirrors SMTP config, masks imap_pass,
SSRF host guard) + GET /received (paginated log).
- server.js starts the poller at boot.
Verified: node -c, require-graph, migration-128 harness (imap columns, flag,
received_emails). Frontend (IMAP block under SMTP + Received tab + flag card)
follows.
Replaces the Company/Event toggle + numeric Event-ID input with a single
EventBookingSelect dropdown (Company = null, else a specific event, fetched via
eventsService). Used by both the incoming-invoice triage and the expense add
form. Projects stay a separate aggregation of events and are intentionally not
a booking target here.
Verified: tsc --noEmit clean; npm run build green.
New Settings -> Accounting tab (gated by the accounting flag) to edit the km
rate, per-diem rate and the "require proof for expense" toggle (reads GET /
writes PUT /admin/settings/accounting). Rates are CHF, stored as integer minor
units; carries the "verify with your Treuhaender" disclaimer. Wired into
SettingsPage (TabType, keys, flag-gated nav item, render) + the features barrel.
i18n: settings.accounting.* (EN + DE, DE native).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
Matches the backend split. Incoming invoices and Expenses are now distinct
surfaces with no shared rows.
Incoming invoices (AccountingInboxPage): triage sets the disposition + booking
(event or company) ON the document; "Mark paid" / "Paid" toggle records
supplier payment HERE with the outstanding total shown; re-bill via the
customer picker + markup. PDF preview still rasterised (last page = QR-bill).
Expenses (ExpensesLedgerPage): internal own-costs only. Add form has a Type
dropdown (amount / mileage(km) / per-diem); km/per-diem switch the input to a
quantity + rate (default from accounting settings, per-entry override) with a
live computed amount; optional proof upload (required when the setting says so);
localized category; booked to an event or the company. Proof viewable per row.
Service: reworked to the new endpoints/shapes; categoryLabel() localizes seed
categories (custom stay free-text). i18n: accounting.booking / incoming /
expense / expenseKind / category (EN + DE, DE native).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
Implements the split decided in review:
Incoming invoices (external) - the inbound_documents row IS the payable:
- categorizeInbound now UPDATES the document (disposition + tax_treatment +
booking event_id (null=company) + category), no derived expense row, so a
supplier invoice appears only in the incoming-invoices surface.
- rebillInbound mints the client invoice from the document (base = invoice
total + markup) and links it on the doc.
- markInboundSupplierPayment records supplier payment ON the incoming invoice
(mark-paid lives here now).
Expenses (internal) - own costs only:
- createExpense: kind = amount|mileage|per_diem; amount = quantity x rate
(rate from accounting settings, per-entry override; snapshotted); optional
proof file; booked to an event or the company; require-proof enforced from
settings. No supplier payment, always own-cost.
- listExpenses returns internal rows only (inbound_document_id IS NULL).
Routes: per-flag gating (incomingInvoices vs expenses; categories on the
accounting master); supplier-payment + re-bill moved under /inbound/:id/*;
POST/PATCH expenses accept a multipart proof upload; GET /:id/proof streams it
(PDF download-only, image inline). getAccountingSettings reads app_settings.
Verified: node -c, require-graph, 12 unit tests (markup + expense amount/build).
Frontend rework (service + the two UIs + settings tab + category i18n) follows.
Foundation for separating external supplier invoices from internal expenses,
per design review. This stage is additive + buildable; the service/route/UI
data rework follows in stage 2.
- Migration 126: incoming invoices own their payable on inbound_documents
(supplier_paid/at/method/ref + disposition + tax_treatment + booking event_id
+ category_id + re-bill markup/linkage); expenses gain kind (amount/mileage/
per_diem) + quantity + snapshotted rate_minor. Additive, hasColumn-guarded.
- Migration 127: seed `expenses` feature flag (default off) + accounting
app_settings (accounting_km_rate_minor=70, accounting_per_diem_rate_minor=0,
accounting_require_proof=false).
- Backend: `expenses` added to feature-flag known/defaults/dependency (forced
off when the accounting master is off); new PUT /admin/settings/accounting
(read via the generic GET /:type).
- Frontend: `expenses` flag (type + context + dependency); Features tab gets an
Expenses sub-card; the Expenses sub-nav + route now gate on `expenses` (not
incomingInvoices); AccountingIndex prefers inbox -> expenses -> tax.
- i18n: settings.features.expenses.* (EN + DE).
Verified: node -c; migration 124->126->127 harness (new columns, flag, settings
+ idempotency); en/de JSON valid; npm run build green.
Covers the silently-regressable money + classification bits of the re-bill
flow (the maintainer's "thin CRM test coverage" concern). Pure functions via a
new expenseService._internal export — no DB, no date-harness pitfalls:
- computeMarkupMinor: percent rounding, flat, none/null.
- resolveMarkup precedence: override > expense clause > none.
- buildExpenseInsert: bad-disposition guard, tax_treatment/status defaults,
declined -> status+reason, markup field matches type, parked -> status.
11 tests, all green (npx jest expenseService.markup).
Adds an "Add expense" action to the expenses ledger for costs with no inbound
document — mileage, per-diem, a cash receipt, etc.
- accounting.service: createExpense() -> POST /admin/expenses
(createManualExpense); CategorizePayload gains `description`.
- ExpensesLedgerPage: AddExpenseModal with supplier / description / amount /
currency / disposition (company expense / pass-through / re-bill — no
duplicate, there's no document to dedupe). Company-expense picks a category;
re-bill uses the customer picker + markup and chains createExpense -> rebill
into an editable scheduled invoice, same as inbox triage. "Add expense"
button in the filter row.
- i18n: accounting.ledger.{addExpense,addTitle,description,descriptionHint,
createdToast} (EN + DE); shared field labels reuse accounting.inbox.field.*.
Verified: en/de JSON valid; npm run build green.
Security hardening for inbound supplier-invoice previews. The admin UI no
longer renders raw PDFs — a malicious inbound PDF could otherwise run embedded
JS or phone home in the admin's session. Instead PDFs are rasterised to flat
PNGs server-side and only those images are shown.
- backend: new rasterizeService shells out to poppler `pdftoppm` (added to the
Docker image via apk poppler-utils — an OS package, NOT a Node PDF lib, so it
respects the pdfkit+pdf-lib "no third PDF lib" rule). pdftoppm executes no JS
and fetches no remote resources, so it doubles as the SSRF/phone-home guard.
Rendered pages cached under storage/business-docs/inbound/rendered/<id>/.
- GET /inbound/:id/page/:n streams the rasterised PNG (CSP default-src 'none'
+ nosniff). GET /inbound/:id/file now serves PDFs as a DOWNLOAD only
(Content-Disposition: attachment) — never inline; images still inline.
- frontend: triage preview switched from a raw-PDF <iframe> to rasterised page
images (getInboundPageBlob), defaulting to the LAST page (QR-bill) with
prev/next nav for multi-page PDFs; images stream as before.
- i18n: previewError / prevPage / nextPage / pageOf (EN + DE).
REQUIRES A BACKEND IMAGE REBUILD (Dockerfile adds poppler-utils) — a plain
`docker compose pull` of a stale image won't have pdftoppm; the route then
returns 503 RASTERIZER_UNAVAILABLE and the UI shows "preview unavailable".
Verified: node -c, a pdfkit->pdftoppm rasterise smoke test (renders + caches),
en/de JSON valid, npm run build green.
Adds Accounting → Expenses, the view of everything triaged out of the inbox:
- ExpensesLedgerPage: filter by status / disposition; each row shows the
disposition + status badge, CHF amount, created date, and a link to the
client invoice for re-billed items. Supplier-payment toggle ("Mark paid" ->
method + date + reference modal; "Paid" -> click to revert) wired to
/:id/supplier-payment. Payment status is decoupled from categorisation, per
the locked design; declined/duplicate rows skip the toggle.
- AccountingLayout: "Expenses" sub-nav item (gated by incomingInvoices).
- App.tsx: /admin/accounting/expenses route.
- i18n: accounting.subnav.expenses, accounting.ledger/expenseStatus/
paymentMethod (EN + DE, DE authored natively).
Verified: en/de JSON valid; npm run build green.
Instead of OCR, let the admin read the payment slip directly: the triage modal
now embeds the captured document and, for PDFs, opens at the LAST page scrolled
to the Swiss QR-bill area so IBAN/amount/reference are visible while typing.
- backend: capture PDF page count at upload via pdf-lib (new
inbound_documents.page_count, added to in-flight migration 124); new
GET /api/admin/expenses/inbound/:id/file streams the stored file inline
(safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own
uploads); the hardened rasterise-in-isolated-worker path stays a follow-up.
- frontend: getInboundFileBlob fetches the file with Bearer auth as a blob;
the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300,
<img> for camera photos) in a two-column layout next to the form.
- i18n: accounting.inbox.previewLoading / qrHint (EN + DE).
Verified: node -c, require-graph, migration-124 harness (page_count), npm run
build green.
Adds the Accounting → Incoming invoices frontend on top of the existing
/api/admin/expenses backend:
- accounting.service.ts: typed client (inbound upload/list/get/update/
categorize, expense list, re-bill, supplier-payment, categories).
- AccountingInboxPage: capture a supplier invoice via the device CAMERA
(<input accept="image/*" capture="environment">) or a PDF/image upload;
inbox list with status badges + parsed summary; a triage modal to confirm
fields and pick a disposition (re-bill / pass-through / company expense /
duplicate / declined). Re-bill uses the customer picker and mints an
editable scheduled invoice (chains categorize -> rebill).
- AccountingLayout: "Incoming invoices" sub-nav item + AccountingIndex that
redirects /admin/accounting to the first enabled sub-feature.
- App.tsx: /admin/accounting/inbox route (gated by incomingInvoices).
- i18n: accounting.inbox/disposition/markup + subnav.incomingInvoices +
common.saving (EN + DE, DE authored natively).
Camera capture needs no native app — the mobile web input drives the device
camera straight into the upload endpoint. OCR/QR auto-extraction is still a
backend follow-up (extractionService is a no-op), so fields are confirmed
manually in the triage modal for now.
Verified: npm run build green; en/de JSON valid.
Replaces the earlier peer-`accounting` flag (which only *conditionally*
relocated Tax) with a cleaner top-level master + sub-toggle model, per design
discussion:
- `accounting` = explicit top-level MASTER (Settings -> Features). Off hides
the whole Accounting section.
- Sub-toggles, gated under the master:
- `taxReport` ("Tax export") moves PERMANENTLY out of CRM. Removed from the
Clients sub-nav and from the derived `clients` flag. Now INDEPENDENT of
Bills (per decision). Old /admin/clients/tax-report -> redirect to
/admin/accounting/tax-report.
- `incomingInvoices` (new) gates the supplier-invoice capture / expenses /
re-bill feature; the /api/admin/expenses router now checks it.
- Dependency rules (backend + frontend): accounting off forces taxReport +
incomingInvoices off; taxReport dropped from the clients derivation; the
bills->taxReport rule removed.
- Preserve visuals: migration 122 rewritten to auto-enable `accounting` on
installs that already had Tax on (so the tab doesn't vanish), and to seed
`incomingInvoices` off. Verified with a SQLite harness (taxReport on ->
accounting on; off -> off).
- Settings -> Features: new "Accounting" section with the master card + Tax
export + Incoming invoices sub-cards (disabled until the master is on).
- i18n: navigation.accounting, accounting.*, settings.features.{accounting,
incomingInvoices,taxReport.requiresAccounting}, sections.accounting (EN + DE,
DE authored natively); Tax report relabelled "Tax export"/"Steuerexport".
Verified: node -c, migration-122 harness, en/de JSON valid, npm run build green.
Adds the `accounting` feature flag to the frontend (type, context default) and
a Settings -> Features toggle card. When enabled:
- A new top-level "Accounting" sidebar entry appears (gated by `accounting` +
accounting.view), with an AccountingLayout sub-nav mirroring ClientsLayout.
- The Tax report relocates: it is HIDDEN from the CRM (Clients) sub-nav and
shown under Accounting instead, at /admin/accounting/tax-report. When
accounting is OFF, Tax stays under CRM exactly as before.
Tax visibility still depends on `taxReport` (which depends on `bills`), so the
relocation only changes WHERE the menu item lives, not whether it exists.
Files: featureFlags.service.ts (+'accounting'), FeatureFlagsContext default,
AdminSidebar entry, new AccountingLayout, ClientsLayout filter, App.tsx route,
FeaturesTab card, en/de i18n (navigation.accounting, accounting.*,
settings.features.accounting; DE authored natively).
Verified: `npm run build` green; en/de JSON valid.
New top-level Accounting area (gated by an `accounting` feature flag, default
OFF, + accounting.view/manage permissions), separate from CRM. Lets an admin
capture a received supplier invoice (upload OR phone/tablet camera), give it a
disposition, and re-bill the cost to a client onto the relevant event's
invoice with a contract-driven markup. Mirrors the billable-hours model.
Backend foundation only — frontend pages (inbox / expenses UI + camera widget)
and the heavy extractors (Tesseract OCR / Swiss-QR decode / isolated rasterise
worker) are follow-ups; extractionService is scaffolded so the upload path is
already wired.
Migrations 122-125 (numbered above the in-flight feat/crm 117-121):
- 122 seed `accounting` flag (default OFF, idempotent)
- 123 seed accounting.view/manage permissions + grant super_admin/admin
- 124 inbound_documents + expenses + expense_categories (+ seed categories)
- 125 contracts Spesen-Zuschlag clause (expense_markup_type/_percent/_flat_minor)
API: /api/admin/expenses — inbound capture/list/confirm/categorize, expense
CRUD, /:id/rebill (event-scoped; markup = expense override -> contract clause
-> 0%; mints an editable scheduled invoice), /:id/supplier-payment, categories.
adminFeatureFlags KNOWN_FLAGS/DEFAULT_FLAGS gain `accounting`.
Conventions: idempotent hasTable/hasColumn-guarded migrations; money in integer
*_minor; QR amount stored separately + untrusted; requirePermission guards;
camelCase API <-> snake_case columns; multer + 15MB cap for PDF/JPEG/PNG.
VAT/tax handling is v1 capture-only — verify with a Treuhaender before relying.
Verified: node -c all files, require-graph smoke test, and a SQLite migration
harness (schema + seeds + idempotency + defaults assert green).
Zszywany reported on v3.44.0 (Ubuntu, Postgres 15): with Settings →
General → "Max Files per Upload" set to 10, an event configured with
allow_user_uploads + a guest selecting 16 files in the gallery's
"Upload Photos" modal succeeded silently — admin uploads to the same
event correctly refused with "Upload limit reached". On top of that,
the gallery modal's "fileRequirements" hint literally rendered
`{{limit}}` instead of the configured number.
Two separate misses for the guest path, both fixed here:
1. **Backend enforcement** — `backend/src/routes/gallery.js:1641` had
`limits: { fileSize: 50MB, files: 10 }` and `.array('photos', 10)`
hardcoded. The admin path at adminPhotos.js:131 has always resolved
files-per-batch via `getMaxFilesPerUpload()` (cached 60s read of
`general_max_files_per_upload`); guest path just never used it.
Mirror the admin: `const maxFilesPerUpload = await getMaxFilesPerUpload()`
and feed multer both `limits.files` AND the `.array(...)` cap. The
50MB per-file size is a separate concern from this issue and stays
as-is for now.
2. **i18n interpolation missing on the guest modal** —
`UserPhotoUpload.tsx:203` called `t('upload.fileRequirements')` with
no arguments. The translation string at `en.json:160` is
"JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)"
— `{{limit}}` is unbound, so i18next emits it literally. The admin
variant `PhotoUpload.tsx:414` correctly passes
`{ limit: maxFilesPerUpload }`.
Also wired up the same client-side count guard the admin component
uses: addFiles refuses additions past the limit (`upload.limitReached`)
and warns on partial-truncate (`upload.someFilesSkipped`). Backend
enforces too, but the client guard saves a 4MB+ multipart POST when
the user is clearly over.
To surface the setting on the guest side, `general_max_files_per_upload`
joins the public-settings whitelist + projection (publicSettings.js)
and the `PublicSettings` TS interface gets the new field. Default
fallback (500, matching `uploadSettings.js` DEFAULT_MAX_FILES_PER_UPLOAD)
in both backend projection and frontend reader so an install that's
never set the value renders a sensible number rather than "undefined".
jodrmx reported on v3.44.0 (Pi Lite, Docker compose): admin-UI event
delete removes the DB row but leaves `storage/events/active/<event>/`
intact on disk.
Root cause: `deleteEventCascade` in adminEvents.js read
`event.folder_path` and gated the `fs.rm` on it. That column is NEVER
WRITTEN anywhere in the codebase — grep confirms two reads in this one
function, zero writes elsewhere. So `event.folder_path` was always
undefined, `if (event.folder_path)` always false, and the per-folder
cleanup silently no-op'd for every delete. The DB-cascade transaction
ran fine, so the symptom was always "row gone, files stay" — exactly
what jodrmx hit.
The actual on-disk location is `events/active/{slug}` everywhere else
in the codebase:
- adminPhotos.js:260 — `path.posix.join('events/active', event.slug)`
- adminEvents.js:610, events.js:155, adminThumbnails.js:153 — read
from `events/active/{slug}`
- adminArchives.js:171 — reads from same root
- photoResolver.js:14-15 — documents the layout
The delete cascade was the only path looking at the non-existent column.
Cure: drop the `if (event.folder_path)` guard, read `event.slug`
instead, and remove from both `events/active/{slug}` (active gallery
folder) and `events/archived/{slug}` (the post-archive copy that
survives the archive flow). `event.slug` is NOT NULL and slugify-
sanitized (lower-case ASCII + dashes only via utils/slug.js), so the
path is well-formed and path-traversal-safe. Best-effort `fs.rm`
semantics + try/catch unchanged — failures still log a warning rather
than unwinding the DB transaction, since orphan files are recoverable
noise compared to a half-deleted DB row.
Forward fix only — does not retroactively clean up the orphans that
have accumulated on existing installs. Admins can `rm -rf
storage/events/active/<old-slug>` manually for those; not worth a
migration script for a one-time deploy ritual.
patchingfailed reported on v3.44 stable: a gallery named `Ägypten` with
photo `Ägypten_individual_0050.jpg` downloads as `gypten_...` —
the leading umlaut is dropped entirely. Their hypothesis was a
Content-Disposition encoding issue, but the actual root cause sits
one layer earlier: at UPLOAD time when `generatePhotoFilename` calls
`sanitizeFilename`.
`sanitizeFilename` did:
String(str).trim()
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_\-\.]/g, '') // ← drops `Ä` outright
.replace(/[_\-]{2,}/g, '_')
.replace(/^[_\-]+|[_\-]+$/g, ''); // ← would strip a leading _ too
For `Ägypten`: alphanumeric-strip → `gypten` (Ä gone, no underscore
left behind because the regex used '' as the replacement, not '_'). The
result is stored in `photos.filename` and that's what downloads serve.
By that point `buildContentDisposition` is doing the right thing
(emits both `filename="..."` ASCII fallback AND RFC 5987
`filename*=UTF-8''…` — Chrome correctly picks the UTF-8 form), but the
string it's encoding has already lost the umlaut at the DB layer.
Cure: NFD-normalize + strip combining marks BEFORE the alphanumeric
strip. Same pipeline `utils/slug.js` (#525) already uses for URL
slugs:
sanitized = sanitized
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
Now `Ägypten` → NFD-decomposed `A` + combining diaeresis → strip
combining mark → `Agypten` survives the alphanumeric pass. Filename
and URL slug stay in sync (the URL was already `Agypten`, per
patchingfailed's report — the filename now matches).
Test surface: new `filenameSanitizer.test.js` pins:
- the headline #607 contract for German / Portuguese / French / Spanish
accented inputs (with a counter-example using the pre-fix pipeline so
a future edit can't quietly regress it)
- ASCII-input parity — pre-#607 byte-identical output for every
pre-existing ASCII case
- `generatePhotoFilename` composed round-trip
- `sanitizeForContentDisposition` + `buildContentDisposition` RFC 6266
dual-form output (since the helper sits next to this function and is
the next thing to break if a refactor goes sideways)
- `sanitizeForZipEntry` path-traversal blocking
31 cases total, all pass.
Bundled into PR #609 since it's a small targeted fix and that PR is
already an admin-UI polish branch with low review weight.
Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:
1. **Broken logo URL rendered the browser's broken-image icon + alt
text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
so a 404 / slow logo URL produced the default broken-image rendering
— which uses the `alt` attribute (`companyName`) as text. Visually it
looked like the wordmark span had unexpectedly re-appeared on phone,
even though the actual `<span>` was correctly hidden by the existing
`wordmarkVisibilityClass` logic.
Fix:
- `useState` tracks `logoLoadError` (first failure) and
`fallbackLoadError` (second failure). On a configured-URL miss the
`<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
a second miss the `<img>` is removed from the DOM entirely.
- `useEffect([resolvedLogoUrl])` resets both flags when the URL
changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
gets a fresh attempt instead of being permanently sad.
- `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
(showLogo && !fallbackLoadError) — when both the configured URL
AND the bundled fallback have failed, the wordmark un-hides on <sm
so the phone header isn't completely empty.
2. **Sidebar VersionInfo + StorageInfo vanished during the
permission-hydration window.** The bottom block was gated on
`hasPermission('settings.view')` directly, which returns `false`
while `PermissionsContext.isLoading` is still resolving (a few
hundred ms right after a deploy when the auth context bootstraps).
Net effect: the whole "Version / Storage" block was absent on first
paint, then re-appeared once permissions hydrated — Rekoo-PS read
that flash as "backend version + storage missing".
Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
Optimistic render during hydration; permitted users see the widgets
immediately (with each widget's own internal loading state), denied
users still see nothing once the permission state lands as `false`.
Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
linkDealToProject(dealUuid, projectId): links every quote + contract sharing
the deal_uuid, re-points the events the deal converted into (so their
invoices/emails/gallery roll up), and adopts the deal's customer onto an
empty project. Invoked from the assign endpoints AND the quote/contract
editors' project picker (create + update). Drop a quote on an empty project
and its linked contract, event and invoices populate the cockpit automatically.
Verified on a booted DB: assignQuote on an empty project propagates project_id
to the contract + event, adopts the customer, and the overview rolls up all
four document types.
A project has at most one customer, so the hours picker shouldn't offer
other customers' projects. HoursSection now passes customerAccountId to
ProjectSelect (shows this customer's projects + still-unassigned ones).
Backend createEntry rejects a projectId owned by a different customer
(422 PROJECT_CUSTOMER_MISMATCH) as defence-in-depth behind the picker.
A long unbreakable token (e.g. the gallery link) overflowed the email
container and forced the admin to side-scroll. The preview HTML prep now
also injects overflow-wrap:break-word so long words/URLs wrap within the
container. break-word only triggers on overflow, so table layout is
unaffected. (Renamed neutralizeLinks → preparePreviewHtml.)
The project create/update/assign routes required 'events.manage', which is
not a real permission (the event perms are view/create/edit/delete/archive).
Since it's absent from the permissions table, even super_admin's all-perms
set excluded it, so every write 403'd with 'Insufficient permissions'.
Switched the write routes to the existing 'events.edit'. (Reads keep
events.view; cockpit doc gating + email actions already use real keys.)
Inside the preview iframe the Accept button navigated but other links didn't
— inconsistent, and worse, clicking Accept/Decline would hit the live action
URLs and change the quote state. Sandbox the iframe (no popups/scripts/forms)
and force all anchors to target=_blank so every link is inert. Now nothing in
the preview is clickable (consistent + safe); scrolling and brand colors are
unaffected.
The email wrapper already sets body/container/text backgrounds from the
brand email-theme settings (email_body_bg_color etc.), so a dark preview is
the configured design — forcing it light was wrong. Render the email as-is;
set the iframe color-scheme to 'normal' only so the admin's dark app theme
doesn't leak into the iframe's UA defaults. The brand's light/dark choice is
respected.
Document rows navigate on click, but email rows only had clickable action
buttons — the row itself was dead, which read as inconsistent. The whole
email row now opens the preview; the action buttons stopPropagation so
Resend/Cancel/Retry/Send-now still fire without also opening the preview.
Every actionable feed row is now uniformly clickable.
The cockpit surfaces quotes/invoices/contracts by PERMISSION, but their
detail routes are gated by feature FLAG (RequireFeature). With those flags
off, clicking a row navigated to a route that redirects to /admin/dashboard
— so links 'did nothing' while the email action buttons (plain API calls)
worked. hrefFor now returns null when the destination flag is off, so the
row renders as non-clickable text instead of a dead link. Galleries/events
are never flag-gated, so they always link.
Each email in the rollup now carries a 'stored' flag (rendered_html present).
Emails without an exact stored copy show an amber '≈ re-rendered' tag next to
Preview, so it's visible at a glance — not just inside the modal. en + de.
- The customer address often doubles as the admin notification target, so
matching emails purely by recipient swept in system alerts (backup_failed,
restore_failed, …). The recipient match is now restricted to CRM document
types (quote_/contract_/invoice_/storno_); event-scoped mails still match
by event_id.
- getEmailPreview now falls back to renderQueuedEmail() — re-rendering from
the current template + the row's stored email_data — for emails sent before
rendered_html capture, flagged exact:false with an amber 're-rendered' note.
Only a missing template / no variables falls through to 'nothing stored'.
Known limitation: a customer with multiple projects sees their event_id=null
CRM mails under each (email_queue has no project_id).
- Milestones + feed rows now link to the document (quote/contract/bill
detail, event for galleries); hours have no page so stay non-clickable.
- Email rollup also matches the project customer's address — quote/invoice/
contract mails are queued with event_id=null, so the by-event scope alone
showed none (hence 'no email preview'). Now they appear with preview.
- Feed amounts coerce total_amount_minor with Number(): Postgres returns
bigint as a string, which formatMoneyMinor's Number.isFinite check
rejected and rendered as CHF 0.00. (computeValuation already coerced.)
- computeValuation helper: per deal_uuid, the invoice total (installments
summed, storno netted) wins over the quote; contracts carry no total so
never contribute. Summed across the project's events, split by currency.
- Value column on the Project Overview list + a value/paid block in the
cockpit header. Both gated by bills.view/quotes.view so no figure leaks.
- listProjects computes all values in two bulk queries (not per-project).
- en + de i18n; six unit assertions cover the rule's edge cases.
Search any event by name and attach it to the project (re-points
events.project_id via assignEvent). Lists the project's current events
above the search. en + de i18n. Completes event grouping UX — admins
can now regroup the auto-created per-event projects however they like.
- ProjectSelect: a reusable picker that renders nothing when the projects
flag is off (satisfies 'book to project hidden unless projects enabled').
- projects.service.ts: full frontend API client (list/get/create/update,
overview, assign event/quote/contract, email preview + 4 actions).
- Quote + contract editors carry an optional projectId (state, prefill,
payload); service payload/detail types updated.
- HoursSection gains a 'book to project' control; backend createEntry
persists project_id (migration 118, hasColumnCached guarded).
- Migration 121 adds quotes.project_id + contracts.project_id (nullable FK,
index) and backfills the unambiguous single-project-per-customer case.
- projectService rolls quotes/contracts up by project_id, with a
customer-based fallback on pre-121 DBs (hasColumnCached guarded).
- quote/contract create+update accept an optional projectId; detail
transforms surface it for editor prefill.
- POST /projects/:id/quotes and /:id/contracts assign endpoints.
processEmailQueue now stores the actual rendered HTML in email_queue
.rendered_html on a successful send (sendTemplateEmail returns it). Guarded
by hasColumnCached so installs without migration 119 just skip it; never
blocks the send. Powers the cockpit's exact-sent email preview.
Backend API for the cockpit (admin-only, Model A):
- projectService: list/get/create/update, assignEvent (re-point events.project_id),
getProjectOverview (rollup — invoices/emails/gallery by event, quotes/contracts
by customer since they carry no event_id, hours by project_id, + a milestone
timeline), getEmailPreview (actual sent HTML).
- adminProjects routes (/api/admin/projects): read=events.view, write=events.manage;
the overview gates each money-doc type on the admin's own bills/quotes/contracts
.view permission. Registered in server.js.
All aggregation queries verified against the real schema on a temp DB.
Data model for the admin-only Project Overview cockpit (Model A — projects
group events; money docs stay per-event and roll up).
- migration 117: projects table (name, customer_account_id nullable, status)
+ events.project_id FK; backfill one auto-project per existing event (1:1
default, customer = the event's single assignment when unambiguous), admins
relink freely afterward. 1 project : N events.
- migration 118: customer_hour_entries.project_id (book hours to a project).
- migration 119: email_queue.rendered_html (store actual sent HTML for the
cockpit's email preview).
All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full
migration boot + backfill on a temp DB.
From dev testing:
- BillEditor 'Geplanter Versand' was a native <input type=datetime-local> →
rendered US date + 12h regardless of settings. Split into LocalizedDateInput
+ TimeField (honour general_date_format + general_time_format), recombined
into the YYYY-MM-DDTHH:MM the payload/scheduler expect.
- InstallmentsPanel 'Send on' native <input type=date> (browser-locale via a
lang hint, wrong in Safari/Firefox) → LocalizedDateInput, consistent in every
browser. (Luca approved converting it.)
- Business-profile Timezone was a free-text input → dropdown of the full IANA
list (Intl.supportedValuesOf, CH/LI fallback), blank = system default.
- Recent Activity rendered literal {{email}} — the per-row t() call didn't
pass the email interpolation var. Source it like formatActivityMessage
(metadata.email ?? actorName).
- Customer 'Deine Galerien' dates rendered en-US ('May','Jun') under a German
UI because they used raw date-fns format(parseISO(iso),'PP') with no locale.
Route through useLocalizedDate().format → honours general_date_format + the
active language.
Addresses the maintainer's non-blocking review items + the Outlook email bug:
- invoice create: verify the chosen event belongs to the customer (only when
the event has assignments; legacy unassigned events pass through).
- mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year
can't silently drop a payment out of every cash-basis revenue window.
- customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize
(was isString/max:2 — allowed '', '1', '!@'), matching the business-profile
route.
- email transporter: close the previous instance before re-init (leak guard
for a future pooled transport).
- scheduled-email tz: warn loudly when business_hours is set but the profile
timezone is blank (was silently using the server/UTC tz).
- wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and
inline the themed CTA button, so the design survives Outlook/Apple Mail
stripping the head <style> (kept the <style> as progressive enhancement).
The /favicon.ico + /apple-touch-icon routes stream the file directly,
bypassing the secureStatic middleware that locks down served SVGs. An
admin-uploaded SVG favicon with <script> would then run at the top-level
origin (stored XSS). Re-apply the same CSP (default-src 'none') + nosniff
for .svg here, mirroring secureStatic.js. Reported in the #603 review.
Two complaints in Rekoo-PS's 3.60.1-beta.0 follow-up screenshots:
1. "Logo took some time to load" — header appeared empty for the
~hundreds-of-ms window between admin mount and `usePublicSettings()`
resolving. The previous code rendered the static fallback
`/picpeak-kamera-transparent.png` during that window, which often
either 404'd or loaded after the rest of the chrome, and because the
wordmark is `hidden sm:inline` whenever a logo is intended to be
shown, phone-width admins saw an empty left cluster instead of
anything.
Cure: render a small pulsing skeleton block (h-8 w-8 on <sm, w-32
on sm+) while `brandingLoading === true`. Same h-8 footprint as the
real logo image so there's no layout shift when the real payload
arrives. Once the public-settings query settles, the normal brand
block renders against known state.
2. "Moving the languages inside the profile tab" — Rekoo-PS argues
language is set-once and shouldn't occupy permanent header real
estate on mobile (4 widgets in the right cluster on phone is
crowded). I agree.
On <sm: header LanguageSelector is hidden (`hidden sm:block` wrapper
around the existing component). A collapsible Language section is
added at the top of the user-menu dropdown showing the current
flag/name + chevron-down. Expanding shows the 8 supported languages
as inline rows highlighting the active one. Picking a language fires
i18n.changeLanguage and closes the menu.
On sm+: header LanguageSelector stays where it was. The user-menu
Language section is suppressed (`sm:hidden`) so the same control
isn't surfaced twice.
Also: `useOnClickOutside(userMenuRef, …)` and the in-menu action
handlers now route through a shared `closeUserMenu()` helper that
also resets the lang sub-section state, so re-opening the menu
doesn't surprise the user with the language list still expanded.
`SUPPORTED_LANGUAGES` re-exported from `components/common` so
AdminHeader doesn't reach into `LanguageSelector.tsx` directly.
No behaviour change on `sm+` — pure phone-view layout fix +
loading-state polish. Locales unaffected (uses the already-existing
language names from SUPPORTED_LANGUAGES).
Mirror the onboarding fix on the customer profile (Rechnungsadresse): replace
the free-text 2-char Country input with the CountrySelect dropdown and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
The accept-invite (onboarding) address form used a free-text 2-char Country
input sitting above State/region. Replace it with the CountrySelect dropdown
(same component as the admin customer + business-profile forms) and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
The test-email, save-config, and flush mutations all showed the generic
'Failed to save changes' toast on error, hiding the actual backend reason —
so a failing test email looked like a save failure and gave no diagnosis.
Show response.data.error / .details (SMTP auth/connection failure, masked
password, private-host rejection, …) with the generic string as fallback.
Two gaps left emails stuck 'pending' after (re)configuring SMTP:
1. Saving the email config never re-initialised the transporter. The queue
processor only re-inits when its cached transporter is null, so a changed
SMTP account had no effect until a backend restart. Now call
initializeTransporter(true) after save (it self-catches; invalid config
just leaves it null, surfaced via the Test-email button).
2. The manual 'send now' flush (ignoreSchedule) still enforced retry_count<3,
so emails that failed 3× while SMTP was broken could never be retried from
the UI. Move the retry-cap (and schedule gate) to automatic runs only;
a manual flush forces a retry of every pending email.
The customer detail + business profile forms showed both a Country picker
(stores the ISO code) and a free-text 'Country (full name)' override
(migration 107). Now that the picker offers the full ISO list and the PDF
renderer derives the localized full name from the code (pdfService.countryName,
used as 'country_name || derive' for both issuer and recipient), the free-text
field is redundant. Remove the input from both forms. The DB column + the
fallback stay, so any legacy override still renders.
index.html hardcoded <link rel=icon href=/favicon-32x32.png>. When the HTML
declares a favicon link, the browser uses it and NEVER requests /favicon.ico
— so Safari showed the bundled default and our dynamic backend route was
never hit (direct /favicon.ico was correct, but the tab wasn't). DynamicFavicon's
JS swap is exactly what Safari ignores.
Point the link at /favicon.ico (backend dynamic route) + add apple-touch-icon,
no type/sizes so the response content-type wins. Now the configured favicon
shows from first paint in every browser, Safari included.
The /favicon.ico route 302-redirected to the uploaded file. Firefox/Chrome
follow that, but Safari does NOT reliably follow a redirect for favicon
requests — it falls back to the HTML <link>, i.e. the bundled picpeak
default. Stream the file bytes directly for local /uploads favicons (with a
path-containment guard); only external URLs and the missing-favicon fallback
still redirect. sendFile sets the content-type from the extension.
Per decision: keep dashboard revenue windows on pure cash basis (recognise
by paid_at for ALL invoices) and give the admin control over paid_at.
- adminDashboard: revert the imported-vs-native split; winSum is paid_at >=
cutoff for every paid invoice again (clean cash basis).
- BillDetailPage mark-paid dialog: add an optional 'Payment date' field
(LocalizedDateInput, defaults to today) so a payment can be backdated to
when it actually arrived. Backend already accepted paidAt end-to-end
(route validator + markPaid service + payment-log) — only the UI was
missing. EN/DE 'bills.payment.date' added.
This fixes the collapsed 30=90=365 windows (they were collapsing because
many invoices were marked paid in one session, all stamped 'now').
The historical-invoice import (and any form whose date field has a non-empty
default like today) lost a typed date: the value was only pushed to the parent
on blur, so submitting while the field was focused — or before React
re-rendered after the blur-time setState — sent the stale default. Issued/
event dates came out as 'today' instead of the entered date.
Now commit as soon as a complete, valid date is entered (toIso returns '' for
partial input, so intermediate keystrokes emit nothing); blur still normalises
display + handles clearing. Applies to every LocalizedDateInput consumer.
Safari requests /favicon.ico and /apple-touch-icon*.png at the site root and
is unreliable about honouring JS-injected <link rel=icon>, so an admin-set
favicon never showed there (index.html only ships /favicon-32x32.png; a bare
/favicon.ico 404'd).
- Backend: GET /favicon.ico + /apple-touch-icon(.png|-precomposed.png) resolve
the configured branding_favicon_url (redirect to its /uploads path or the
absolute URL), falling back to the bundled /favicon-32x32.png.
- nginx: exact-match (=) locations proxy those paths to the backend, winning
over the static-asset regex that previously served them from the build dir.
- DynamicFavicon also emits an apple-touch-icon link (belt-and-braces).
Requires a frontend image REBUILD (nginx.conf change) in addition to backend.
The dashboard revenue windows (30/90/365 days) keyed purely on paid_at.
Imported historical invoices therefore landed in the recent window whenever
their paid_at sat there — notably legacy rows imported before commit c6b8cc9
began anchoring an import's paid_at to its issue_date, which still carry an
import-time paid_at. Recognise imported invoices (imported_pdf_path NOT NULL)
on their issue_date instead; native invoices keep cash-basis paid_at. No data
migration needed — fixes already-imported year-old invoices too.
Extends the dark-logo fix to the customer login, customer accept-invite,
customer reset-password, and gallery client-access pages — they all rendered
only the light logo on the themed (possibly dark) surface.
Also makes the login-page pick frame-aware: a framed login logo sits on a
fixed cream plate, so the light (dark-ink) logo always reads there; only the
frameless logo sits on the themed page background and uses the dark variant.
This corrects the admin login too (was unconditionally swapping when dark).
Customer/gallery pages read isDark from usePublicDarkMode (branding_force_
color_mode + OS fallback), matching CustomerLayout.
The public quote, contract-signing, and payment-check pages baked a single
light logo (the contract page showed none), so the dark page rendered a
dark-text logo on a dark background.
- usePublicDarkMode now returns { isDark } (reactive) alongside applying
the .dark class, so pages can pick a theme-aware asset.
- The three public routes now surface both branding logo URLs (logoUrl +
logoUrlDark) in the issuer block; the contract issuer gains a logo too.
- QuoteResponsePage, ContractResponsePage, and the payment-check
BrandingHeader pick the dark variant when isDark, falling back to
whichever exists. Covers the accept/accepted states of each page.
The SVG->PNG cache was keyed only by source path + mtime + size, so an
override logo rasterised once WITHOUT fonts (text -> tofu) stayed cached
after the font fix - the source SVG was unchanged, so the stale tofu PNG
kept being served. Add a RASTER_VERSION component to the cache key; bumping
it (v2-fonts) invalidates every prior rasterisation without clearing the
cache dir by hand.
The favicon upload allowed only PNG/ICO, so an SVG favicon was rejected.
Accept image/svg+xml (.svg) too - DynamicFavicon already emits the right
MIME type and served SVGs are CSP-locked (render-only) by secureStatic.
Update the EN/DE help text accordingly.
The login page only ever rendered branding_logo_url (the light logo), so a
dark-text logo sat on the dark background in dark mode. Pick the dark
variant via useAdminDarkMode (honouring branding_force_color_mode too),
mirroring AdminHeader/AdminSidebar, with a fallback to whichever exists.
The runtime image (node:22-alpine) shipped without any fonts, so when
sharp/librsvg rasterised an SVG logo containing live <text> for the CRM
PDFs, the vector artwork drew but the text rendered as tofu boxes - a
'corrupted' logo on invoices/quotes.
- Add fontconfig + DejaVu/Liberation (broad Unicode fallback) and refresh
the font cache.
- Register picpeak's own bundled brand fonts (assets/fonts/<Family>/*.ttf -
the same files PDFKit and the web UI already use) with fontconfig via a
conf.d <dir> entry + fc-cache, so the logo's text renders in its ACTUAL
brand typeface rather than a generic fallback.
The upload never enforced 32x32 - only the help text recommended it,
which misled admins. Update the EN/DE guidance to recommend a larger
square image (512x512) and raise the favicon upload cap 1MB -> 2MB so
high-resolution PNGs fit comfortably.
The link was an <a target="_blank"> doing a hard SPA boot in a new tab,
which tripped the error boundary. Switch to a react-router <Link> so it
opens Settings -> CRM the same way the sidebar nav does (known-good path).
The country dropdown was a curated 22-entry European subset; expand it to
the complete ISO 3166-1 alpha-2 set so customers from any country can be
selected. Labels are still derived from Intl.DisplayNames and sorted by
localized name at render time, so no translation map is needed.
GalleryPreview used only branding.logo_url, so the Live Preview kept the
light logo when previewing a dark theme. Pick the logo by theme.colorMode
(symmetric fallback) and pass logo_url_dark through from BrandingPage.
The sidebar brand row (logo_position=sidepanel) + collapsed rail used
the light logo unconditionally. Make it theme-aware via useAdminDarkMode
with the same symmetric fallback as the header (dark uses dark||light,
light uses light||dark). This was the missing admin surface — the header
already switched.
The whole contracts.detail.* namespace was English-fallback-only, so the
contract detail page rendered English in German. Add all 64 keys to en +
de (native German), covering actions, signing/counter-sign, audit trail,
convert, and PDF flows. Also dedupe a duplicate events.createInvoice key
(identical value).
Serve uploaded SVGs (admin logos etc.) with a restrictive
Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline';
img-src 'self' data:) + X-Content-Type-Options: nosniff in secureStatic.
The browser still renders the vector, but any embedded <script>/on*
handler can't execute if the SVG is opened directly — keeps real SVGs
(scalable) instead of rasterising them. Applies to all secureStatic
mounts (uploads/photos/thumbnails/fonts); only SVGs get the header.
After 'Create draft invoice' mints the single scheduled invoice from a
per-event customer's unbilled hours, navigate to the bill editor so the
admin can add other line items before it ships (invoice is already
status='scheduled' + editable). Updated the per-event hint copy.
New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
DynamicFavicon hardcoded link.type='image/png', so an SVG/.ico favicon
was declared as PNG and browsers ignored it. Derive the type from the
file extension instead. (Sidebar icon already uses <img> which renders
SVG fine.)
Pick the logo by the active color mode with a symmetric fallback: a
single uploaded logo serves both modes (dark uses dark||light, light
uses light||dark). Apply to admin header, customer gallery, and the
customer portal (follows branding_force_color_mode). PDFs already use
the light branding logo with the business-profile PDF logo as override
(resolveLogoFile) — unchanged.
Add an optional dark-mode logo (branding_logo_url_dark) alongside the
main logo. Upload/remove via the logo endpoint (?variant=dark) on the
Branding settings page. Admin header (admin dark mode) and the public
gallery (dark themes) pick the dark logo when active, falling back to
the light logo when unset. PDFs keep using the light logo.
Add a 'Configure defaults in Settings' link (opens Settings → CRM in a
new tab) under the payment-conditions section of the quote and invoice
editors, so the admin can jump to the payment-term / Skonto / numbering
defaults without hunting for the settings page.
Add a 'Preview PDF' button on draft contracts that renders a fresh PDF
via the existing no-write /preview endpoint, so the admin can check
layout + signature blocks before sending (no audit trail created).
LocalizedDateInput.toDisplay only matched a bare yyyy-MM-dd, but Postgres
serializes DATE columns as a full ISO datetime, so the field printed the
raw "2026-…T…Z" string (SQLite returned a bare date, hiding it). Match the
leading yyyy-MM-dd of any ISO value and slice the hidden native picker's
value to 10 chars. Fixes ISO-form dates on customer/event/passive-create.
Add a bills-gated button that opens the bill editor pre-filled with the
event (eventId FK + name/date snapshot) and the linked customer (when
exactly one). BillEditorPage gains eventId state + query-param prefill +
sends eventId on create; backend validates eventId (already forwarded +
persisted). Reuses the editor — no empty drafts. Does not auto-pull hours.
queueEmail gains options.respectBusinessHours: snaps the send time to the
next open business-hours block (from now), only deferring when it actually
falls outside hours. Applied to dunning reminders + gallery-expiry warnings;
transactional/admin-initiated mail stays immediate. No-op until business
hours are configured.
Native <input type="time"> ignores general_time_format (browser-locale
controlled; lang hint failed in Safari and Ralf's Chrome for both en-GB
and de-DE). Replace with a custom TimeField text input that displays per
general_time_format (24h "13:00" / 12h "01:00 PM") and stores canonical
HH:MM, parsing tolerant free-text on blur. Keeps the fixed-width
alignment fix.
Business-hours time pickers used lang="en-GB", which didn't render 24h in
Chrome. Switch to lang={timeFormat==='12h'?'en-US':'de-DE'} — the same hint
HoursSection/Quote/Bill/Contract editors use — so the picker shows 24h in
Chrome/Edge. Keep the fixed-width plain <input> for column alignment.
The shared Input wraps fields in a w-full div, so two per flex row split
the width and the trailing +/trash buttons knocked columns out of
alignment. Use a plain fixed-width <input> for the start/end time fields.
Route 10 surfaces that hardcoded 12-hour date-fns patterns
('h:mm a', 'PPp', 'p', toLocaleTimeString) through useLocalizedDate's
formatDateTime/formatTime so they respect general_date_format +
general_time_format: backup/restore, archives, photo viewer, feedback,
event details, gallery timeline, public quote page, CMS save indicator.
Also pin a lang hint on the business-hours native time inputs (Chrome/Edge
render 24h). Drops now-unused date-fns imports.
Native <input type="time"> renders AM/PM from the browser locale, ignoring
general_time_format. Pin a lang hint (en-GB for 24h, en-US for 12h) so
Chrome/Edge render the admin's chosen format. Display-only; the stored
value was already 24h HH:MM.
The t() calls on the Features tab, CRM settings tab, contract editor,
and settings nav carried English fallbacks but the keys were absent from
both locale files, so DE rendered English. Add ~67 keys to en.json +
de.json (DE native): the full contracts.editor.* subtree (whole page was
English), settings.features.{contracts,crmDevelopment,hoursLogging}.*,
crmSettings contracts/dashboard-overview/ToS labels, and two settings
nav titles. Also drop a dead duplicate bills.field.sourceQuote key in de.
No code changes — additive translations only.
Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
Add a "Decline on behalf" action mirroring accept-on-behalf, for when a
customer says no by phone/email. Flips a draft/sent/expired quote to
declined, stamps declined_at, closes the public response window, and
invalidates outstanding accept/decline tokens so the emailed link can't
toggle it back. Optional free-text reason persisted to a new
quotes.decline_reason column (migration 115) and shown on the quote
detail page. Hard-delete intentionally not included.
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
When no customer is selected, list every customer with unbilled hour
entries — entry count, total hours, and open amount (resolved via the
override → customer-rate → install-default chain). Rows with no
resolvable rate are flagged "Rate not set" rather than undercounted.
Click a row to drill into the per-customer logging section.
Backend: getUnbilledSummaryByCustomer() + GET
/api/admin/customers/hour-entries/unbilled-summary (customers.view).
Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
Replace the sort <select> dropdowns on the invoice, quote and contract
list pages with clickable column headers that toggle asc/desc and show a
chevron indicator. Adds a shared SortableHeader component + useColumnSort
hook that maps clickable columns onto the server-side sort enum.
Make issue date (newest first) the standard sort on all three lists,
set at the frontend, route and service layers. Adds issue_asc/issue_desc
to invoices and an "Issued" column to the bills table so the default is
visible and toggleable. Extends sort coverage so every clickable column
has both directions (+customer_desc on all; +issue_asc/desc on
quotes/contracts). Storno rows remain listed.
The Anlass field rendered inv.eventDate verbatim (raw ISO from pg
date-as-Date serialization) while every other date on the card went
through useLocalizedDate. Route it through fmtDate so it honors the
general_date_format setting. (The event_id → /admin/events linkify was
already in place.)
The historical-invoice import form had no event field, so imported rows
landed with event_name = NULL even when the admin knew the occasion. Add
free-text Event name + Event date inputs to the import modal, thread them
through billsService.importHistorical and the POST /admin/invoices/import
validator, and store them in the event_name/event_date snapshot columns
(migration 107). event_id stays NULL — no FK, since the event may predate
picpeak. Autocomplete-to-event_id linking deferred as a future bonus.
The business-profile country field — the seed source for the customer-create
country default — was still a free-text input placeholdered "FL", which could
reintroduce the non-ISO "FL" code that migration 110 normalized to "LI" and
re-open the create/edit CH-vs-FL default mismatch. Swap it for the shared
CountrySelect so every surface stores ISO alpha-2. The free-text countryName
verbatim-PDF override (migration 107) is unchanged.
Adds customer_accounts.skonto_disabled (migration 112) so a customer
that negotiated "no early-payment discount" can be flagged once instead
of ticking the per-invoice toggle on every invoice. resolveSkontoPercent
ForInvoice and the PDF render context both honour it, extending the
resolution chain to customer → invoice → snapshot → quote → global.
Checkbox added to the customer detail Billing card (en + de).
A scheduled invoice's issue_date was stamped at creation, so a long-
scheduled invoice printed a stale date by the time it shipped — the
relative Skonto window ("pay within N working days") and the net-days
due date were then counted from the authoring day, not the send day.
sendInvoice now stamps issue_date = send date on the first send and
re-derives the due date from it, preserving a manual due-date override.
Adds resolveNetDaysForRow to read net days from the persisted snapshot.
Deselecting Skonto before the scheduled send already propagates (the
scheduler re-reads the row fresh and the render context honours
skonto_disabled); no change needed there.
The Anlass / event name on the invoice detail page and the bills list
now links through to /admin/events/:id when the invoice references a
real event row. The list link stops propagation so it doesn't trigger
the row's invoice navigation. Falls back to plain text when the invoice
carries only a free-text event snapshot. Customer portal unchanged
(no admin route access).
Due date now derives from (scheduled send date else issue date) plus the
selected Net-days template, both in the editor and on save. The bill
editor renders it read-only with an Override toggle for manual entry;
existing invoices preserve their stored due date. Backend adds a single
resolveNetDays resolver that honors the split payment-net-days template
(previously only the legacy FK was read) and the
crm_payment_default_net_days setting, used by createInvoice and the
installment-spawn path alike.
The invoice-import endpoint stamped sent_at and paid_at with the moment
of import (new Date()) instead of the document's historical dates. The
CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a
year-old paid invoice imported today wrongly counted toward the rolling
window. The dashboard windowing is correct (cash-basis "received in the
window") — the bug was the wrong paid_at on imported rows.
POST /admin/invoices/import now anchors sent_at to issue_date and
paid_at to issue_date (or an optional new paidAt param when the admin
knows the real payment date), never to import time.
Migration 111 backfills rows imported under the old behaviour: for every
invoice with imported_pdf_path set, sent_at/paid_at are reset to
issue_date. The old code never captured a real payment date, so
issue_date is the only sensible anchor. Idempotent and scoped strictly
to imported rows, so picpeak-issued invoices are untouched.
paid_at/sent_at are operational timestamps, not the invoice's immutable
legal content, so correcting the import-time error is safe under the
§14/§11 UStG immutability rule.
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.
Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).
Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.
i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
Admin date inputs were inconsistent: raw <input type="date"> on event
creation and the bill editor rendered in the browser locale (en-US users
saw MM/DD/YYYY regardless of Settings -> General), while the historical-
invoice import modal used a private LocalizedDateField that displayed the
configured format but showed a text box plus a tiny native date stub
side-by-side ("two date fields, looks corrupted").
Extract a single shared LocalizedDateInput that displays/parses in the
configured general_date_format on every browser and opens the native
picker via a calendar icon button (showPicker on a visually-hidden native
input), so there is one date field, not two. Wire it into event creation,
the bill editor (event/issue/due dates), the import modal, and the tax-
report range filters (dropping the Chromium-only lang={dateInputLang}
workaround there).
Caught during the round-4 e2e validation on real PG: every
successful restore landed with `status='completed', was_successful=false`
because the success-branch update only wrote `status` but not
`was_successful` (column default is false). Visible side effect: the
BackupDashboard's "last successful restore" filter would skip the
row + any future audit query gating on was_successful would miss it.
One-line cure: include `was_successful: true` in the success-branch
update payload. Inline comment explains why and references the
review note so future edits keep the two fields together.
Source-inspection test in restoreService.pgBranch.test.js pins the
contract: after `performPostRestoreVerification(...)`, the
`status: 'completed'` update payload must also contain
`was_successful: true`. Future refactors of the success payload that
drop the flag fail the test before merge.
36/36 backup-related integration tests pass.
End-to-end DR cycle surfaced one more PG-only landmine — and it
turned out to be a side-effect of the round-1 replay placement, not
a new bug. Round 2 fixed the comparison logic; round 3 fixes the
ordering.
Symptom on real PG install:
[install-from-backup] FAILED — Post-restore verification failed:
Table app_settings row count mismatch: expected 190, got 191.
Trigger file left in place for retry.
Root cause: the operator-meta replay (introduced in round 1) ran
INSIDE performDatabaseRestore, lined up BEFORE the post-restore
verification step in the parent restore() method. So:
1. psql restores app_settings → 190 rows (matches backup)
2. Replay upserts `restore_allow_force_auto_upgraded` (which the
fresh-install seeded but the backup didn't have) → 191 rows
3. performPostRestoreVerification counts 191, manifest says 190,
verification fails the row-count check.
Replay is doing the right thing (preserving operator policy). The
verification is doing the right thing (counts must match). They
disagree because the replay landed in the wrong sequence relative
to verification.
Cure: move the replay out of performDatabaseRestore and into
restore() AFTER `performPostRestoreVerification` passes.
Verification now sees the as-restored DB (matches the backup
exactly), replay layers on top once verification has signed off.
Mechanism: snapshot stashed on `this.preservedMetaSnapshot`
(initialised in constructor, reset per run at the top of restore()).
performDatabaseRestore writes it in the PG branch before DROP;
restore() drains it after verification. SQLite leaves it empty,
both steps no-op there.
Tests:
- Updated `restoreService.pgBranch.test.js` to pin the new shape:
* `this.preservedMetaSnapshot` is initialised in the constructor
* No stray `let preservedMeta = []` local declarations anywhere
* Replay drain (`this.preservedMetaSnapshot.length > 0`) sits in
restore() AFTER `performPostRestoreVerification(...)` and is
lexically OUTSIDE `performDatabaseRestore`.
- The bigint-as-string contract from round 2 still holds.
34/34 backup-related integration tests pass.
pg-driver serialises `bigint` (which is what `COUNT(*)` returns) as a
JavaScript STRING to preserve precision for huge counts. The manifest
stores `expected.rowCount` as a JS number (parseInt'd at
databaseBackup.js:118). Strict `!==` in performPostRestoreVerification
flagged every match as a mismatch on PG:
Table activity_logs row count mismatch: expected 16, got 16
Table admin_users row count mismatch: expected 1, got 1
Table app_settings row count mismatch: expected 165, got 165
... (every table, all matching)
Symptom matched the preservedMeta scope leak from round 1: install-
from-backup logged FAILED, trigger file wasn't cleaned, data was
actually intact. Caught on PR #596 e2e re-run.
Cure: coerce both sides with `Number(...)` at the comparison AND in
the interpolated value so the warning text renders `16` not `"16"`.
Pre-emptive: lines 448 + 458-459 had the same string-vs-number issue
masked by `>` (JS coerces operands for `>`), but the warning text
printed `"5"` on PG vs `5` on SQLite, and a future patch changing
`>` to `=== 0` or `!== expectedCount` would silently break on PG.
Coerced at the read site into `eventCountN` / `activeUsersN` locals
+ added a comment block explaining the contract so future edits
don't drop the Number() calls without re-auditing.
New source-inspection test: pins the contract that every `.count`
result in restoreService.js MUST be wrapped in `Number(...)` when
used in a comparison (===/!==/>/</>=/<=). Same source-inspection
pattern as the preservedMeta test added round 1 — pragmatic until
the real-PG integration test follow-up lands.
The maintainer's audit of the rest of the backup/restore surface
(_installFromBackupBoot, _restoreSettingsBoot, _backupPathsBoot,
backupCoverageService, backupIntegrityService, backupService,
databaseBackup) confirmed no other bigint-as-string sites — the
class is now closed in the audited scope.
Two nice-to-haves from the PR #596 review.
1. Install-from-backup logging mirrors to stdout
The winston logger writes to /app/logs/combined.log and may not
tee to stdout. Operators tailing `docker logs picpeak-beta-backend`
after a `compose up` saw the migration sweep + npm notice and
nothing about the restore. Three key events now also fire through
`console.log` with a `[install-from-backup] ` prefix:
- "trigger file detected → <manifest>"
- "starting restore from <manifest>"
- "restore completed successfully" / "FAILED — <reason>"
Plus the "skipping — existing data" branch.
docker-logs surface now tells the restore story without requiring
an `exec into the container` step.
2. ADMIN_CREDENTIALS.txt flags stale creds when restore is queued
Migration 001 detects a pending `RESTORE_ON_INSTALL` file BEFORE
writing the fresh-install credentials file. If a trigger will fire
on the next boot, the file now opens with a clear warning:
⚠️ RESTORE_ON_INSTALL TRIGGER DETECTED ⚠️
These credentials are temporary. An install-from-backup run is
queued to fire on the next server start, which will REPLACE
this admin row with the one from the backup. After the restore
completes, log in with your ORIGINAL pre-disaster credentials
— not the ones below. If the restore fails for some reason,
the credentials below remain valid as a fallback recovery path.
Doesn't skip the file (so a failed restore still has the fallback
credentials), just annotates it. Closes the maintainer's "stale
junk credentials" observation.
`preservedMeta` was declared with `let` INSIDE the PostgreSQL else
branch of performDatabaseRestore (~L850), then read AFTER the else
block closed at the shared replay site (~L1030). On every real PG
restore, this threw:
ReferenceError: preservedMeta is not defined
after psql had already loaded the data successfully. Knock-on
effects per the maintainer's review:
- Loud `Install-from-backup: FAILED` line in combined.log even
though the data restored cleanly
- Trigger file in `_installFromBackupBoot.js` was left in place
because the success branch never ran — admin had to manually
rm it before the next boot
- The operator-meta replay (restore_allow_force,
restore_allow_force_auto_upgraded) silently dropped, exactly
the chicken-and-egg the snapshot was added to close.
`restore_allow_force` reverted to the backup's value on every
PG restore.
CI missed it because integration tests around `performFullRestore`
only exercise the SQLite branch (`this.dbType === 'sqlite'`). The PG
branch requires a real psql binary + cluster, which lives in the
"real-PG integration test in CI" follow-up.
Cure: hoist the `const PRESERVED_META_KEYS = [...]` + `let
preservedMeta = []` declarations above the SQLite/PG split. SQLite
leaves them empty; PG branch fills them; replay block at the bottom
reads them on both paths (no-op on SQLite).
New test: `restoreService.pgBranch.test.js` pins the scope contract
via source inspection. Two assertions:
1. Exactly one `let preservedMeta = []` declaration in the file,
positioned before the SQLite/PG branch split
2. The replay block `if (preservedMeta.length > 0)` sits outside
the else block (closing ` }` exists between the branch
opener and the replay site)
Source-inspection beats a runtime test here because (a) it doesn't
need a real PG cluster + psql binary, (b) it pins the EXACT property
that broke, more directly than a runtime test would.
Closes PR #596 review blocker.
The AdminHeader "Clear All" notifications button has been 404'ing for
a while: frontend `notifications.service.ts` calls
`DELETE /admin/notifications/clear-all`, backend only defined
`DELETE /admin/notifications/clear-old`.
The /clear-old route was misleadingly named anyway — it tried to
delete read OR >30-days-old rows, then had a fallback that nuked
EVERY row when nothing matched. Both the frontend and the existing
test expect a simple Clear All shape, so just rename to /clear-all,
drop the tiered logic, and return the plain
`{ message, deletedCount }` payload the test asserts on.
The test (adminNotifications.test.js) was hiding the breakage —
it was on CI's --testPathIgnorePatterns ignore list and so never
ran. Two reasons it failed locally before this fix:
1. Route path mismatch (the actual #597 bug).
2. The mock only stubbed adminAuth — requirePermission lives in
its own middleware module and ran for real, 403'ing before
the handler. Add a passthrough mock for that too.
With both fixed, the test passes. Drop adminNotifications from the
CI ignore list so future regressions in this route fail loudly
instead of going to ground.
upstream/beta independently shipped 108_seed_sl_email_template_translations.js
(Slovenian email template translations) using the migration number
this branch had already claimed for 108_add_backup_paths.js. Knex's
filename-based ordering would have caused both to attempt the slot
at merge time.
Renamed via `git mv` so file history is preserved. All five
references updated in lockstep:
- backend/src/services/_backupPathsBoot.js (require + comments)
- backend/src/services/backupService.js (LEGACY_BACKUP_PATHS comment)
- 3 integration test files (require + "migration 108" prose)
- migration's own header comment, with a paragraph explaining the
rename so reviewers don't wonder why the number jumped
**No data-migration impact for installs that already ran the
108-named version** (Ralf's beta, primarily): the migration's body
is idempotent — createTable is guarded by `hasTable`, and the seed
uses `onConflict('path').ignore()`. So when 109 runs against an
install whose backup_paths table is already populated, both the
schema step and the seed step no-op cleanly. The orphaned
`108_add_backup_paths.js` row in the `migrations` tracking table
sits harmlessly alongside the new `109_add_backup_paths.js` row.
No data lost, no double-insert, no schema drift. Mechanical rename
ahead of the PR opening.
The previous split (separate docs/install-from-backup.md + separate
README link for "Disaster Recovery") fragmented what's conceptually
one workflow: backup → restore. DR is a specific scenario of restore
(the destination is wiped), not a separate feature.
This merge:
- Folds install-from-backup content into docs/backup-restore.md
as a "Disaster recovery (install from a backup)" section with
its own table-of-contents anchor.
- Adds an explicit ToC at the top so admins land on what they
need in one click.
- Frames the two restore paths up front: "live install" → wizard,
"fresh / wiped install" → trigger file. Admins encountering DR
in panic mode don't need to know to look under a separate link.
- Drops the duplicate "Disaster Recovery" README bullet. The
"Backup & Restore" blurb now mentions DR explicitly so it's
still findable via Ctrl+F on the README.
- Removes docs/install-from-backup.md (its content is now in
backup-restore.md's DR section).
Single source of truth = less risk of one doc going stale relative
to the other when the feature evolves. Maintainer-facing surface
on docs.picpeak.app shrinks back to one /guides/backup-restore page.
The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.
Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.
All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).
Tokens replaced:
format(date, 'p') → formatTime(date)
format(date, 'PP') → format(date)
format(date, 'PPP') → format(date)
format(date, 'PPp') → formatDateTime(date)
format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
format(date, 'yyyy-MM-dd HH:mm') → formatDateTime(date)
No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.
Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.
Truncate stays in place as defensive depth for the text_only path.
Closes the six-step DR dance ("onboard throwaway admin → restore via
wizard → log out → log back in with originals") by letting admins
recover an install with zero clicks past `docker compose up`.
Convention: drop a file named `RESTORE_ON_INSTALL` (no extension OR
.txt) into the existing `/backup` bind mount. On next container
start, the new boot hook detects it, runs the restore, and starts
the server with the restored state. Admin opens the browser, login
works first try.
Payload variants:
- empty file → auto-picks newest backup-manifest-*.json from
/backup/manifests/. Useful for "restore the latest".
- path inside the file → uses that specific manifest. Useful for
"I want this older backup, not the most recent".
Safety gates (three layers):
1. Trigger file must exist — no auto-magic, admin signals intent
2. DB must be empty (no events, ≤1 admin) — refuses to clobber
production data
3. Restore failure leaves the trigger file in place for retry on
next container start. Success deletes it so subsequent boots
don't redo the work.
Override hook: INSTALL_FROM_BACKUP_FORCE=true skips guard #2 for the
"I know what I'm doing" edge case (dev env rebuilds, etc).
No docker-compose changes required — uses the bind mount picpeak
already has, env vars are optional. The minimal admin workflow now
matches the bare-minimum mental model: "copy my backup files,
restart the container, log in with original credentials."
Tests: 7 scenarios covering trigger detection, payload variants,
safety gates, success/failure trigger-file lifecycle.
The #592 fix added a devtools-detection probe, and the #592 follow-up
added a require_password probe + a branding-defaults whereIn().select().
Both shift the db() call indices the existing #550 test relied on, and
the branding probe needed `.select()` to resolve to an array (the mock
chain wasn't thenable, so `for..of` on the result threw → 500 on every
test that hit BASE_BODY).
Add `whereIn` + `selectResult` to buildChain so the branding probe
yields an iterable. Factor the three pre-slug app_settings chains into
a baseSettingsChains() helper and update each test's queued sequence
and toHaveBeenNthCalledWith / toHaveBeenCalledTimes expectations to
match the new shape. No behaviour change in v1/events.js — only the
test scaffolding moves.
Closes the last gap from tonight's backup-hardening: backup_runs.
statistics now carries a `per_path` map keyed by backup_paths.path
(e.g. `events/active`, `business-docs`), with per-bucket count + size.
Backend (backupService.js):
- new `computePerPathStats(backedUpFiles, allFiles)` helper that
bucket-sorts each backed-up file into its owning backup_paths row
by longest-prefix match. Reuses the same backup_paths source the
walker reads, so toggling include_in_default off propagates
correctly. Falls back to LEGACY_BACKUP_PATHS if the table is
missing.
- runBackupInternal calls it after the destination implementation
reports back, includes the result in statistics under both
snake_case (`per_path`) and camelCase (`perPath`) keys for the
same alias treatment the existing fields get.
Frontend (BackupHistory.jsx):
- Backup History detail pane now renders one row per per_path entry
when present, with path label + count + formatted size.
- Falls back to the legacy Photos / Archives / "Other" rendering
when the field is absent (backups taken before this commit). No
breaking change for stored history.
Tests: new backupService.perPathStats.test.js — 2 scenarios pinning
attribution behaviour (single-path, nested-paths-don't-collide).
Plus a NOTE comment about overlapping-path walker behaviour (out of
scope; canonical seed doesn't hit it).
Closes the chicken-and-egg where `restore_allow_force` (and its
auto-upgrade tracking flag) got overwritten on every restore by
whatever value happened to be in the backup. Net effect:
1. Admin enables Force Restore (via tonight's default-ON migration
edit, or hand-SQL on older installs).
2. Restore runs successfully.
3. Restored DB has `restore_allow_force = <backup's old value>`.
4. Next restore attempt: "Force restore is not allowed by system
settings" — admin needs the SQL workaround AGAIN.
Cure: snapshot a small list of operator-meta keys BEFORE the DROP
DATABASE (while we still have a working pool against the OLD DB),
then UPSERT them back AFTER the psql restore + migrate.latest.
The preserved set is intentionally narrow — currently just
`restore_allow_force` and `restore_allow_force_auto_upgraded`. These
are about how the operator wants the install to behave, not user-
facing state. Adding more keys is a one-line addition to the
PRESERVED_META_KEYS constant.
Survives both:
- backup is OLDER than the operator's most recent setting change
- backup is NEWER but had a different operator policy
Either way, the post-restore install reflects the LIVE operator
policy, not the backup's snapshot of it.
The in-session toggle fix in d292b9f handles click 2 correctly, but
on a hard refresh likedPhotoIds was always initialized to an empty
Set — so previously-liked photos rendered un-filled until the user
opened the lightbox.
Backend: gallery.js GET /:slug/photos now mounts resolveGuest and
emits a per-viewer is_liked boolean per photo. Prefers req.guest.id
when a verified guest token is present (per-person identity), falls
back to the IP+UA hash that generateGuestIdentifier produces — same
identity model galleryFeedback.js uses for /my-feedback. Skipped
when feedback is hidden from guests.
Frontend: Photo type gains optional is_liked. Each of the 7 grid
layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic /
Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked)
on the first non-empty payload, gated by a seededRef so subsequent
React Query refetches don't clobber in-session optimistic toggles.
Mosaic uses photo.is_liked ?? false in its per-card useState initializer.
GalleryPremium also drops the buggy `|| like_count > 0` fallback at
line 521 that treated "anyone liked this" as "I liked it" — the
per-viewer seed is now the correct source.
GalleryStory had the same shape of bug in two places — same #590 fix:
- Seed switched from like_count > 0 (global) to is_liked (per-viewer),
with the same mount-only seededRef guard.
- handleToggleFavorite now calls submitFeedback on EVERY click, not
only when adding. The previous code skipped the unlike submit, so
the UI removed the heart while the server kept the like row.
Same class of bug as the devtools-detection gap landed in 2304b25.
v1 POST /events was hardcoding require_password=true in the destructure
default and skipping getBrandingDefaults entirely, so:
- Admins who disabled "require password by default" globally still
got password-required galleries through the API.
- API-created events ignored the global branding_logo_display_hero
and branding_logo_size toggles, defaulting to visible/medium
regardless of the admin's preferred branding chrome.
Mirror the readBooleanSetting + getBrandingDefaults pattern from
adminEvents.js inline (helpers aren't exported, and pulling them out
is out-of-scope for this fix). Adds validators, fallback resolution,
and the three resolved values to the events insert. hero_logo_position
stays at 'top' since #357 / migration 084 explicitly disconnected it
from the header-bar branding_logo_position setting. OpenAPI updated.
Closes the loop on the original 2026-05-29 data-loss class: Ralf had
four "Run Backup Now" manifests sitting on disk with
database.backup_file = null because Stage A wasn't yet in place.
The restore wizard would have happily restored any of those four,
bringing back files (photos, PDFs) but leaving the database empty —
silently re-creating the exact data loss the rest of this branch
prevents going forward.
The /restore/list-backups endpoint now returns `database_included`
per row (parsed from the manifest at discovery time). The wizard
uses it to:
- Per-row badge: red "No DB" pill next to any backup where
database_included === false. Tooltip explains the consequence
in plain English: "restoring this will NOT recover the database".
- Selected-card callout: full red banner under the chosen row
when database_included is false, restating the warning + giving
the admin a clear path: "pick a different backup if you have
one with a database dump, or proceed only if files-only is
what you want."
The wizard does NOT block the restore — the admin may genuinely want
a files-only restore (e.g. recovering a deleted photo while keeping
current DB state). The warnings make sure that choice is informed.
The dashboard widget used `lastBackup.created_at` for the "Last
successful backup: X ago" text — but lastBackup is the most recent
row of any status. So a crashed restore (status=running, never
updated) or a recent failure showed up labeled as the last
successful backup. Same "silent failure not surfaced" class the
restore wizard had.
Backend now returns:
lastSuccessfulBackup — most recent backup_runs with status='completed'
zombieRuns — running rows older than 30 min (likely crashed mid-flight)
lastBackup — unchanged (most recent any status)
Frontend renders:
- "Last successful backup: X ago" — always from lastSuccessfulBackup
- "Last attempt: Y ago · failed/running" — when lastBackup differs
from lastSuccessful. failed shows the first line of error_message
in red; running stays neutral.
- Zombie callout — "N backup(s) running >30min — may have crashed"
in amber, so admin sees stuck rows at a glance.
- Health score downgrades from "excellent" to "warning" if the
latest attempt failed, even when older successes keep the age
fresh — surfaces regressions without erasing the green history.
The progress step used a binary `isRunning ? "in progress" : "completed"`
check. So when the backend rejected the restore (pre-flight validator
threw, path error, etc.) the wizard cheerfully rendered "Restore
completed" with 0% progress and no error context — the admin had to
SSH into the server and inspect `restore_runs.error_message` to find
out what happened.
Now reads the most recent row from `restoreStatus.history[0]` and
renders one of three states:
- running → blue text, progress bar updates
- succeeded → green tick + post-restore actions (existing behaviour)
- failed → red banner with the first line of error_message, and
a callout if was_rollback_attempted is true so the
admin knows the destination is safe to retry on top of.
Net: the wizard now tells the truth about what just happened.
`db.destroy()` during restore tore down the in-process connection
pool to release PG sessions so DROP DATABASE could succeed. After
CREATE DATABASE + psql restore, the old code did
`require('../database/db')` expecting a fresh instance — but Node
caches require results, so it got the SAME destroyed instance back.
Every subsequent query in the process failed with "Unable to acquire
a connection" until the container was manually restarted, even
though the restore technically succeeded.
Net effect for admins: login showed "An error occurred", customer /
invoice / quote pages were blank, no surface hinted at the dead pool.
Cure: db.js now wraps the live knex instance in a Proxy that forwards
to a mutable internal reference, with a `reinitPool()` function that
destroys the old instance + builds a fresh one + probes with `SELECT 1`
so any reconnect failure surfaces immediately. The thousands of
existing `const { db } = require(...)` imports work unchanged — they
capture the Proxy once, and every call goes through to the current pool.
restoreService calls reinitPool() after CREATE DATABASE and before
migrate.latest(), so the rest of the request + every subsequent admin
action runs against the fresh pool. Container restart no longer
needed after restore.
Same class of bug as #550 part 2 (feedback default ignored on API
events): the events table column default for enable_devtools_protection
is true, so an admin who disabled detection globally still got it ON
for every API-created gallery.
Mirror the feedback fallback that landed in 1b521e7 — accept an
optional enable_devtools_protection body field, fall back to the
app_settings entry of the same name, and write the resolved value
explicitly on insert so the column default doesn't shadow it.
OpenAPI doc updated to match.
Default nginx is 4 8k — too tight when an outer Cloudflare /
corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when
a power-user accumulates many per-gallery gallery_token_<slug>
cookies over the 24h maxAge in tokenUtils.js. Either way users hit
"400 Request Header Or Cookie Too Large" and clearing cookies is
the only workaround.
4×32k is cheap RAM, matches what most reverse proxies do upstream,
and means PicPeak doesn't fail the request before the upstream even
sees it.
The /feedback like endpoint is a server-side toggle — the same one
the lightbox uses. Every grid layout's optimistic-UI setter only
ever did next.add(photoId), so click 2 on a liked tile fired a
server unlike but kept the heart filled in the UI.
Switch each setter to toggle (delete if present, else add). Covers
Masonry (default), Grid, Justified, Timeline, Carousel, Mosaic, and
Premium layouts — including their identity-modal callback paths for
shape consistency. Lightbox toggle is unchanged (already correct).
#527 hid the language *name* on <sm to free space for the title.
Since then the right cluster gained dark-mode toggle, notifications,
and the user avatar, and the brand block still had no truncation —
so a long branding_company_name would still push past the available
width into the action buttons on phones.
Defensive fix: min-w-0 on the brand-block wrapper, truncate on the
company-name span, flex-shrink-0 on the logo image. Long names now
ellipsis within the left cluster regardless of how many widgets
fill the right.
demo.picpeak.app sits behind Caddy + Cloudflare; Caddy replaces the
nginx CSP entirely with one that omits 'unsafe-inline' / hash / nonce,
so the #358 inline theme-bootstrap was being blocked there — admin
loaded a black page, the SPA bundle 404'd, link buttons did nothing.
Move the bootstrap to /public/bootstrap.js served as 'self' so the
script runs under every reasonable CSP without further coordination.
Vite copies /public/* to the dist root at build time (same pipeline
as /favicon-32x32.png), and it remains in <head> without defer/async
so it still runs before <body> paints. The OS-preference @media CSS
above still handles the first-frame dark/light baseline.
Root cause of the persistent "Force restore is not allowed by system
settings" error even on fresh installs after `docker compose down -v`:
migrations/core/032_add_restore_runs_table.js seeded the row with
`JSON.stringify(false)` = the literal string 'false'.
So every install (fresh OR upgraded) wrote restore_allow_force=false
at migration time. The boot self-heal added earlier today saw the row
and respected "admin policy" per its safety design — never noticing
that the row was the deprecated migration default, not an explicit
admin choice.
Cure follows [[feedback_migration_no_compensation]] +
[[feedback_self_heal_pattern]]:
1. Edit migration 032 IN PLACE — flip seed value from false to
true. Fresh installs forward get the correct default at install
time, no boot helper needed.
2. One-time auto-upgrade in _restoreSettingsBoot.js for installs
that already ran the OLD migration. Bumps restore_allow_force
to 'true' iff the current value is the deprecated literal
'false' AND the new tracking key
`restore_allow_force_auto_upgraded` doesn't yet exist. The
tracking flag is always written after the first boot pass, so
subsequent admin choices (e.g. deliberately disabling force)
are preserved on every boot after.
3. Defensive: adminRestore.js getRestoreSettings() now normalizes
'true'/'false'/'"true"'/'"false"' string shapes to JS booleans,
not just '1'/'0'. Belt-and-suspenders so any future seeder that
uses a different boolean serialization doesn't silently break
the !settings.restore_allow_force gate.
Net effect: any picpeak install pulling this image — fresh or
existing — gets restore_allow_force=true on first boot after the
upgrade. The catch-22 that forced every disaster-recovery admin to
hand-write SQL before their FIRST restore is closed.
Fresh installs of picpeak had `restore_allow_force` defaulting to
false (or missing entirely). Combined with the "1 active admin
user" pre-restore warning that the fresh-install admin auto-creates,
this meant the very first restore on every new install hit:
Force restore is not allowed by system settings
Admins then had to hand-craft SQL to flip the setting before they
could recover their data — at the worst possible moment, when they
were already mid-disaster.
This isn't security: the admin who can SQL the setting on can also
flip it via the UI. It's just a sharp edge that bites every new
install once.
Cure: boot-time self-heal that seeds restore_allow_force=true only
when the row doesn't exist. Existing installs that explicitly set
the row (true OR false) are NOT touched — admin policy wins.
Pattern mirrors _backupPathsBoot.js and _emailTemplateBoot.js.
Default-ON rationale matches Stage A's principle: the cost of
forgetting (= can't recover from a disaster) outweighs the friction
saved (= adversarial admins can't run forced restores). Audit
logging keeps the accountability story intact.
The "Content Backed Up" panel in Backup History only counted two
categories (Photos + Archives), so a 3-file backup that landed all
3 in business-docs (Ralf's case after the storage truncation +
restore tonight) showed:
Photos (0 of 0)
Archives (0)
→ total: 3 files
The discrepancy made admins wonder where the 3 files actually went.
Adds two rows:
- "Business documents & other" = files_processed - photos - archives
- "Total files" = files_processed
So the math adds up regardless of which Stage B path the files came
from. Properly per-path-category breakdown requires backend-side
per-path counters (separate follow-up); this commit closes the
visible-discrepancy gap without that schema change.
i18n: en + de added; other locales fall back to en until reviewed.
pg_dump emits setval() statements for SERIAL/IDENTITY columns, but
they don't always land cleanly: --clean ordering, knex pool sequence
caching, rows inserted mid-restore (the pre-restore safety backup
writes a database_backup_runs row before DROP), etc. Net result on
Ralf's install after a successful restore:
- "A record with this value already exists" on every CRUD action
- duplicate key value violates unique constraint
"database_backup_runs_pkey" on the next Run Backup Now
Same root cause: every SERIAL column's sequence was pointing at or
below MAX(id), so the next INSERT collided.
Fix: append a DO block after the psql restore that walks pg_class +
pg_attribute and setval()s every public-schema sequence to
GREATEST(MAX(<col>), 1). Cheap (a few ms even on large schemas),
safe (read-only on row data), idempotent — re-running it just
re-asserts the same values.
Seventh latent PG-restore bug discovered on Ralf's install tonight.
Manual hand-fix worked; this commit makes the fix automatic for
every future restore.
PostgreSQL refuses DROP DATABASE while any session is connected:
ERROR: database "picpeak_prod" is being accessed by other users
DETAIL: There are 6 other sessions using the database.
The backend's own knex pool holds 5-25 active connections to the
target DB. So even after closing the request that initiated the
restore, the pool keeps the DB busy and the DROP statement fails.
Three-layered cure, all in the restore service's PG branch:
1. Call `db.destroy()` first to close the in-process knex pool so
we don't fight ourselves. Knex will lazily re-open on the next
query via db.js's retry logic, so this is safe to do mid-restore.
2. SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE
datname=<target> AND pid<>pg_backend_pid() — evicts any sessions
from other processes (other server replicas, leftover idle
transactions, things our own pool destroy missed).
3. DROP DATABASE IF EXISTS "<target>" WITH (FORCE) — PG13+ kills
remaining connections atomically with the DROP. Falls back to
plain DROP on older Postgres where WITH (FORCE) is a syntax error.
Surfaced as the FIFTH latent bug in the restore path tonight: the
DROP DATABASE statement always assumed a quiescent destination, but
the live backend keeps the destination busy at all times. Every
previous PG install of picpeak that ever tried Restore would have
hit this — meaning the disaster-recovery feature has shipped broken
for a long time without anyone exercising it end-to-end.
`psql` with no -d connects to a database whose name matches the
connecting user. On installs where the user's home DB doesn't exist
(common pattern: DB_USER=picpeak, DB_NAME=picpeak_prod, no `picpeak`
DB), the restore's DROP DATABASE / CREATE DATABASE statements failed
with:
FATAL: database "picpeak" does not exist
even though the target DB (picpeak_prod) was alive and connectable.
And of course you can't connect to the target DB itself for DROP —
PostgreSQL refuses while a connection is open to it.
Fix: explicitly connect to `postgres` (the maintenance DB every PG
cluster ships with) for the DROP/CREATE statements. Override via
DB_CHECK_DB env var if the `postgres` DB is restricted to superusers
on the cluster — matches the pattern wait-for-db.sh already exposes.
Also quote the database name in the SQL so installs whose DB has
unusual characters (numbers, hyphens) don't break the statement.
Surfaced during Ralf's end-to-end restore validation — yet another
"never been tested on a real PG install" latent bug exposed by the
Stage A inline-dump path actually being able to produce a restorable
manifest for the first time on his install.
Two changes that close the disaster-recovery loop the Stage A-B-C
backup-hardening plan opened:
1. Resolve 'local' source to backup_destination_path
The wizard passes options.source = 'local' (the SOURCE TYPE
string). The old code assigned that verbatim to localBackupPath
and every downstream path.join() ended up with junk like
'local/database/<file>.sql.gz'. Fixed by looking up
backup_destination_path from app_settings when source='local',
plus a layered candidate fallback in performDatabaseRestore so
absolute paths in manifests are honoured first.
2. Auto-rollback on ANY failure during restore
Previously rollback only fired when post-restore VERIFICATION
failed (inside the try block). Anything that threw earlier —
path bugs, pg_restore failure, file copy errors — left the
destination half-clobbered with no automatic recovery. Now the
catch block always invokes attemptRollback if a pre-restore
backup exists, and persists rollback status in
was_rollback_attempted + an enriched error_message so the admin
can tell at a glance whether the destination is safe to retry
on top of or needs manual inspection first.
Surfaced during Ralf's validation of the end-to-end backup +
restore cycle (`docker compose down -v` then restore from disk).
Every prior failed attempt left stray PDFs behind that the next
attempt had to navigate around — exactly the "every failure makes
the next worse" pattern this fix kills.
Two stacked bugs in the disaster-recovery path:
1. The wizard passes `options.source = 'local'` (the source TYPE
string) and the service assigned it verbatim to `localBackupPath`.
Every downstream `path.join(localBackupPath, ...)` ended up with
junk like `local/database/<file>.sql.gz` and `local/events/...`.
2. performDatabaseRestore reconstructed the dump path from the
manifest by basename-only:
path.join(backupPath, 'database', path.basename(dbBackupFile))
discarding the absolute path the manifest actually recorded.
Cure:
- At the entry point, if `options.source === 'local'`, look up
`backup_destination_path` from app_settings and use that as the
local root. Honour s3:// downloads via the existing branch.
- In performDatabaseRestore, try the manifest's absolute path
first, then `localRoot + manifest_value`, then the legacy
`localRoot + 'database' + basename` reconstruct as a final
fallback. First hit wins; error message lists every candidate
so future failures are diagnosable.
Surfaced during Ralf's end-to-end validation of the Stage A-B-C
backup-hardening plan — restored fresh after `down -v`, the wizard
failed silently with `Database backup file not found: local/database/...`
even though the dump existed at the path the manifest recorded.
With this fix, the same destruction-and-recovery sequence completes.
The Restore wizard's "Choose Backup to Restore" list was driven only
by the backup_runs table. After `docker compose down -v` (the disaster
this whole hardening effort is designed to recover from), the DB is
empty and the wizard shows "No backups found in selected source" —
exactly when it's needed most. The manifest JSONs are still on disk;
the wizard just can't see them.
Adds disk-first discovery:
- Walks backup_destination_path AND backup_manifest_path (manifests
can live in a sibling directory under the canonical
<root>/manifests/backup-manifest-<id>.json layout). Depth-limited
recursion (3 levels) so the scan doesn't enumerate the photo tree.
- Matches backup-manifest-*.json|yaml AND legacy bare manifest.json.
- Parses each manifest for real metadata (timestamp, size, file
count, database.backup_file presence) instead of showing the
admin opaque filenames.
- Layers in surviving backup_runs rows, deduping by manifest_id.
Applied to both GET /available-backups (legacy) and POST /list-backups
(the one the frontend actually calls). Same helper, two call sites.
Side benefit: each returned row now carries `databaseIncluded` — so a
future Restore UI iteration can show a "this backup has no DB dump"
warning before the admin picks a files-only backup. Exactly the
surface that would have caught Ralf's original four files-only
manifests if it had existed.
`BackupHistory.jsx` opened `/admin/backup/download/<id>` via window.open,
which goes to the React SPA's router — no matching route, so it
rendered the "Page Not Found" screen.
The actual download endpoint lives at `/api/admin/backup/download/:id`
on the backend (adminBackup.js:685). Cookie-based admin auth already
supports the implicit cookie sent by window.open, so the URL prefix
was the only thing missing.
Predates today's backup-hardening work — the bug has existed since
this download button shipped. Surfaced now because Ralf finally has a
completed backup to try downloading after the Stage A inline-dump
guard started working.
pg_dump rejects `--single-transaction` — it's a pg_restore / psql flag,
never a pg_dump one. Triggered as soon as the inline-dump path landed
on Ralf's install:
pg_dump: unrecognized option: single-transaction
pg_dump: hint: Try "pg_dump --help" for more information.
pg_dump already wraps the entire export in a single REPEATABLE READ
snapshot automatically (since Postgres 9.x), so the original intent —
consistent snapshot of the live DB — is preserved by removing the
flag. Same "latent until Stage A wired it in" pattern as the three
prior bugs this rollout has surfaced (PG insert destructure → bind-
mount EACCES → Node 22 stdio strict mode → this).
spawnToFile and spawnFromFile passed an unopened WriteStream/ReadStream
directly as a stdio entry to child_process.spawn. Older Node versions
auto-extracted .fd; Node 22 throws synchronously:
The argument 'stdio' is invalid.
Received WriteStream { fd: null, path: '/backup/database/...sql', ... }
Bug bit Ralf's install once today's `bugfix/crm-backup` image landed —
Node 22 came with that image, and Stage A's inline-dump path is the
first caller of spawnToFile on this install. Latent on the previous
image (Node 20); fatal on this one. restoreService's pre-restore
safety snapshot uses the same helper and would have hit it next time
a restore ran.
Cure: stdio: ['ignore', 'pipe', 'pipe'] (and ['pipe', 'pipe', 'pipe']
for spawnFromFile) + manual pipe of child.stdout/stdin through the
file stream. Works on every Node version. Also wires the WriteStream's
'error' event to the promise via settleReject so a future EACCES /
ENOSPC reaches the caller's try/catch instead of becoming a process-
fatal unhandled error event — closing the same "Stage A guard
bypassed" hole noted in the spawned follow-up task.
Side benefit: outStream.end() now awaits flush before resolving, so
fast pg_dump runs can no longer produce a truncated dump.
databaseBackupService.backup() did `const [runId] = await db(...).insert({...})`
without a .returning() — works on SQLite (knex returns [lastInsertId]) but
throws "(intermediate value) is not iterable" on Postgres (knex returns
a non-iterable shape).
Bug was latent until Stage A of the backup-hardening plan wired this
method into the "Run Backup Now" inline-dump path. Before Stage A only
the scheduled cron + the dedicated admin-DB-backup page called it, and
Ralf's install had never exercised either — so the inline-dump default
landing in production was the first time the destructure ran on his PG.
Cure: same explicit .returning('id') + dual-shape coalesce pattern that
backupService.js uses for its own backup_runs insert (line 949).
Two more sibling files have the same anti-pattern (userManagementService,
customerAccountsService — invitation flows) and will bite under the
same conditions; spawned a follow-up task to fix them in a separate PR.
#574 follow-up — @blazmaric flagged that once an admin user is
deactivated, the UI loses every affordance to manage that record.
The deactivate button hides (rightly — they're already deactivated)
but nothing replaces it, leaving the row stranded in the list with
no path to either restore access or permanently remove it.
## Backend
New on `userManagementService`:
- **`activateAdminUser(id, activatedById)`** — symmetric to
`deactivateAdminUser`. Flips `is_active` back to true, logs
`admin_user_activated` activity. Idempotent: already-active target
short-circuits without bumping `updated_at`. No "can't activate
yourself" guard needed (actor is by definition already active).
- **`deleteAdminUser(id, deletedById)`** — hard-deletes the row.
Same self-action and last-super-admin guards as deactivate.
Last-super-admin guard counts ACTIVE super admins excluding the
target — so an already-deactivated super_admin can still be
deleted when an active super_admin remains. FK ON DELETE rules
in core migrations handle the cascade: SET NULL on
`created_by_admin_id` everywhere (events, photos, quotes,
invoices, contracts, customer_accounts, …); CASCADE on the
user's own `api_tokens` + their pending admin / customer
invitations.
New routes on `adminUsers.js`:
- `POST /api/admin/users/:id/activate` — `users.delete` permission
(same tier as deactivate; reverting deactivation is the same
scope of action as performing it).
- `DELETE /api/admin/users/:id` — `users.delete`.
## Frontend
`UserManagementPage.tsx`:
- New mutation hooks: `activateUserMutation`, `deleteUserMutation`.
- The row's action cell now branches on `user.isActive`: active
users see Edit + Deactivate (unchanged); deactivated users see
Edit + Reactivate (`UserCheck` icon, green hover) + Delete
(`Trash2` icon, red hover).
- The shared `ConfirmDialog` handles all four action types
(deactivate / activate / delete / cancelInvitation) via per-type
title / message / confirmText / variant lookup.
`userManagement.service.ts`:
- New `activateUser(id)` and `deleteUser(id)` methods mirroring the
existing `deactivateUser` shape.
i18n keys are added with English fallbacks via `t(key, fallback)`
so the page works on every locale without a missing-translation
warning. Native translations can be filled in via a follow-up.
## Test plan
- [x] 8 new service tests pin: activate happy-path, idempotency on
already-active, NotFoundError on missing target, activity log
emitted, delete self-refusal, last-super-admin guard for both
active and already-deactivated super_admin targets, hard-delete
success, delete activity log.
- [x] Frontend type-check clean.
- [x] Frontend lint clean for the changed files.
- [x] Backend lint clean.
- [ ] Manual: deactivate a user → row now shows Reactivate + Delete
→ reactivate → user can log in again. Then deactivate again →
delete → row vanishes, pending tokens for that user invalidated.
Closes the UX gap blazmaric called out in
https://github.com/the-luap/picpeak/pull/579#issuecomment-... .
Closes#570.
PR #555 shipped the CRM module with strong service-layer coverage
but no HTTP-layer tests. This adds Supertest-based route coverage
across the externally-reachable public routes (P0) and an auth-gate
sweep of every CRM admin route (P1+P2).
## What's covered
### P0 — Public routes (49% of new tests)
The three public routes are the security-sensitive surface — any IP
with the raw token from a leaked email can hit them. Tests pin the
publicTokenGuards.loadActionToken contract end-to-end:
- **publicQuotes** (8 tests) — GET load + POST respond: 404 unknown,
400 malformed, 410 expired, 200 valid w/ sanitised payload (no
customer_account_id / created_by_admin_id leakage), 429 after 20
bad attempts (IP lockout), 400 invalid action.
- **publicContracts** (10 tests) — GET load + POST sign + POST
upload-signed-pdf + GET pdf: same guard outcomes per endpoint,
plus the pre-multer token check (malformed token rejected before
multer reads the body — prevents the disk-spam attack the
preMulterTokenGuard was added for).
- **publicPaymentCheck** (6 tests) — different shape (no
loadActionToken; service does its own validation): validator gate
on token shape, all 4 canonical actions pass through the
validator, negative amountMinor rejected.
The NULL-expires_at defensive branch in loadActionToken is
documented but not tested here — current schema declares
quote/contract_action_tokens.expires_at NOT NULL, so the branch is
unreachable at the route level. Worth a direct unit test on
loadActionToken if anyone wants to cover it.
### P1 + P2 — Admin routes (51% of new tests, 25 cases)
One consolidated `adminCrmAuth.test.js` file rather than nine
per-route files — the auth-gate contract is identical for every CRM
admin route, so a parametrised `describe.each` is more efficient
and lands the same coverage:
Per route (adminQuotes, adminContracts, adminInvoices, adminCalendar,
adminDeals, adminTaxReport, adminBusinessProfile):
- 401 without Authorization header (adminAuth gate)
- 401 with invalid JWT signature (adminAuth signature check)
- 2xx with super-admin token + CRM feature flags on (permission +
feature-flag gates both pass)
Plus 4 tests for the CRM additions in adminCustomers
(hour-entries / bill / trigger-monthly-bill) — those endpoints
are mixed in with pre-existing customer routes, so they get
explicit coverage rather than bulk via the parametrised sweep.
## Harness extensions to integration/helpers/crmDb.js
Three new helpers (one place for any future route test to find):
- `mintAdminToken(adminId, opts)` — JWT signed with the test
JWT_SECRET, shape matches what adminAuth expects.
- `createPublicToken(db, tableName, opts)` — insert a row into
quote/contract_action_tokens with controllable expires_at /
used_at / token. Note: Date values are explicitly ISO-stringified
before insert — bare Date objects round-tripped inconsistently
through knex+SQLite, sometimes via .toString() → literal
`"[object Object]"` which parsed back to NaN and silently defeated
the expiry guard. Caught it in test bring-up.
- `buildRouteApp(mount, router)` — minimal Express app (json + cookies)
with a catch-all error handler that mirrors middleware/errorHandler
(uses err.statusCode, not err.status — getting that wrong silently
maps every 4xx to 500 in tests).
- `assignAdminRole(db, adminId, roleName)` — promotes a seedMinimal
admin into super_admin (or any seeded role) for happy-path tests.
## Out of scope (follow-up)
Deeper integration tests for the document mint/send paths
(adminQuotes.send → PDF persisted + token minted + email queued;
adminInvoices.Storno → new row with shared deal_uuid + original
cancelled; adminContracts.countersign → integrity_hash computed)
are deferred. The service-layer behind those is already covered by
the existing __tests__/services/ suites — this PR pins the
HTTP-layer contract, which is what #570 actually asked for.
## Counts
- 4 new test files, 49 tests total
- ~860 LOC of test code + ~85 LOC of new harness in crmDb.js
- All tests pass in <2.5s (no real network, no real disk except the
per-test tmpdir, no email sending)
Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
- new backupCoverageService.js: per-path coverage classification,
drift detection (top-level subdirs not in backup_paths and not
in the backups/tmp allow-list), DB-dump mode + staleness block
- new GET /api/admin/system-health/backup-coverage route, same
auth + settings.view permission as /backup-integrity
- 7 integration scenarios pinning the classifier behaviour
Frontend:
- new BackupCoverageCard with auto-fetch (cheap; no recursion)
- new Coverage tab on BackupManagement next to Integrity
- en + de i18n; other locales fall back to en keys until a native
speaker reviews
Verification:
- 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
- frontend build clean
- 4 pre-existing integration failures confirmed unrelated
Stage B of the three-stage backup-hardening plan (Stage A:
inline-DB-dump + fail-loud guard already landed). The file-backup
walker used to hard-code its subdirectory list inside
`getFilesToBackupInternal`, which is the same footgun that hid the
`business-docs` gap for ~6 months — a new feature drops artefacts
under STORAGE_PATH and the maintainer has to remember to edit the
walker.
Now driven by a `backup_paths` table:
- Migration 108 creates the table and seeds the 7 canonical
defaults (events/active, events/archived, thumbnails, previews,
heroes, uploads, business-docs). Seed data lives on the
migration as `DEFAULT_PATHS` so the boot self-heal can re-use it.
- `_backupPathsBoot.js` mirrors `_emailTemplateBoot.js`: on every
boot it diffs the canonical list against the current rows and
`INSERT ... ON CONFLICT DO NOTHING`s the missing ones. Keeps
admin edits intact, picks up new defaults shipped after the
install (Knex won't re-run migration 108). Wired into server.js
just before `startBackupService()`.
- Walker now calls `resolveBackupPaths(config)` which:
* reads `backup_paths WHERE include_in_default=true ORDER BY
display_order`
* falls back to a hard-coded `LEGACY_BACKUP_PATHS` if the
table is missing OR empty (defense in depth — never silently
scans nothing)
* gates each row by its `feature_flag` column (matches how
`backup_include_archived` already worked; data-driven now)
- Backward compatible: `getFilesToBackup(true|false)` still works
for legacy callers and the existing businessDocs test. New
callers should pass the full config object so feature gates
other than `backup_include_archived` evaluate correctly.
Tests:
- new: `backupService.configurableWalker.test.js` — 7 cases
covering canonical seed, toggling include_in_default, runtime
INSERT picked up without restart, feature_flag gating both on
and off, empty-table → LEGACY fallback, boolean backward compat
- all 15 backup-walker integration tests pass
(configurableWalker 7 + inlineDbDump 5 + businessDocs 3)
- frontend build clean
- 4 pre-existing integration failures (webhookDelivery, storage
backend, adminPhotos.reference, imageProcessor.storage) confirmed
unrelated via `git stash` baseline run
Stage C (CRM feature coverage audit + diagnostic UI) follows
in a separate commit.
The previous file-backup workflow only LOOKED UP an existing
database dump via getDatabaseBackupInfo() and silently shipped a
files-only manifest when none was found. Admins clicking "Run
Backup Now" (or relying on the schedule) got an apparent success
that omitted every customer / quote / invoice / contract / payment-
log row. The data-loss footgun was discovered 2026-05-29 when an
admin who'd been "backing up" for weeks via the UI lost the entire
CRM after a routine docker compose down -v — every produced
manifest had database: { backup_file: null, size: 0, tables: {} }.
New helper `ensureDatabaseDumpForBackup(config)` encapsulates:
1. Inline pg_dump (or SQLite copy) before the file scan, via
databaseBackupService.backup(). Result lands in
database_backup_runs and is picked up by the existing
getDatabaseBackupInfo lookup that writes the manifest.
2. Fail-loud guard: if no usable dump file is reachable (path
missing, 0 bytes, or never existed), throw — the existing
catch in runBackupInternal marks the backup_runs row failed
with the error_message and emails the admin if configured.
No more silent files-only manifests.
3. Opt-out: `backup_database_inline_dump = false` skips the
inline dump for admins who already run their own scheduled
`backup_database_schedule`. The fail-loud guard still
applies, so an opted-out install with no recent dump still
aborts loudly instead of producing a partial backup. Default
ON is encoded as "skip only when explicitly false" — undefined
(existing installs upgrading) falls through to the safe-
default ON branch.
The helper returns the verified `databaseInfo` so the manifest-build
step at runBackupInternal:917 reuses it instead of calling
getDatabaseBackupInfo a second time. S3/future destinations that
override `result.databaseInfo` are still respected (the existing
`result.databaseInfo ||` fallback shape stays put).
Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.
Stage A of three-stage backup hardening plan. Stage B (config-
driven walker) and Stage C (audit + diagnostic UI) follow in
separate commits.
The previous file-backup workflow only LOOKED UP an existing database
dump via getDatabaseBackupInfo() and silently shipped a files-only
manifest when none was found. Admins clicking "Run Backup Now" (or
relying on the schedule) got an apparent success that omitted every
customer / quote / invoice / contract / payment-log row. The
data-loss footgun was discovered 2026-05-29 when an admin who'd been
"backing up" for weeks via the UI lost the entire CRM after a routine
docker compose down -v — every produced manifest had database:
{ backup_file: null, size: 0, tables: {} }.
Changes to runBackupInternal:
1. Inline pg_dump (or SQLite copy) before the file scan, via
databaseBackupService.backup(). Result lands in
database_backup_runs and is picked up by the existing
getDatabaseBackupInfo lookup that writes the manifest.
2. Fail-loud guard after the dump step: if no usable dump file is
reachable (path missing, 0 bytes, or never existed), throw —
the existing catch block marks the backup_runs row failed with
the error_message and emails the admin if configured. No more
silent files-only manifests.
3. Opt-out: `backup_database_inline_dump = false` skips the inline
dump for admins who already run their own scheduled
`backup_database_schedule`. The fail-loud guard still applies,
so an opted-out install with no recent dump still aborts loudly
instead of producing a partial backup. Default ON is encoded
as "skip only when explicitly false" — undefined (existing
installs upgrading) falls through to the safe-default ON path.
Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.
Stage A of three-stage backup hardening plan. Stage B (config-driven
walker) and Stage C (audit + diagnostic UI) follow in separate
commits.
Closes#580.
Slovenian community contribution from @blazmaric (filed as an issue
with attached files rather than as a PR — files inlined here unchanged
except for the migration number).
## Changes
- **`frontend/src/i18n/locales/sl.json`** — full Slovenian UI
translations. Covers every top-level key present in `en.json` as
of pre-CRM beta. The new CRM-module keys (`bills`,
`businessProfile`, `calendar`, `contracts`, `crm`, `crmDev`,
`crmSettings`, `dealLineage`, `eventReminderOverride`,
`hoursLogging`) are not yet translated and will fall back to
English — same posture as FR / NL / PT / RU / ES currently have
for the CRM module (see PR #555 description).
- **`frontend/src/components/common/LanguageSelector.tsx`** — adds
`SLFlag` SVG component + registers `{ code: 'sl', name:
'Slovenščina', Flag: SLFlag }` in `SUPPORTED_LANGUAGES`. Frontend
i18n auto-discovers locale files via `import.meta.glob` so no
separate config registration is needed.
- **`backend/migrations/core/108_seed_sl_email_template_translations.js`** —
contribution-author's `107_*` filename renumbered to `108_` to
avoid collision with `107_crm_consolidated.js` that landed on beta
in the meantime. Idempotent insert via (template_id, language)
uniqueness check — re-runnable, never overwrites admin edits.
Covers 17 templates: admin invitation / password reset, archive
complete, backup completed / failed, customer gallery assigned,
customer invitation / password reset, database backup completed /
failed, expiration warning, gallery created / expired, restore
completed / failed, version update available / test.
- **`backend/src/services/emailProcessor.js`** — adds `.si → sl` to
the email-domain → language inference map, matching the pattern
for every other supported locale. A customer with `@example.si`
now gets Slovenian emails automatically without needing to set
their preferred_language explicitly.
## Out of scope (consistent with existing locales)
- CRM email templates (quote_sent, invoice_sent, contract_sent, etc.,
seeded at boot by `crmEmailTemplates.ensureCrmEmailTemplatesSeeded`)
will fall back to English for Slovenian customers — those seeders
only emit EN + DE rows today across every locale.
- CRM UI strings under the missing top-level keys listed above will
fall back to English.
Both gaps mirror the existing FR / NL / PT / RU / ES situation.
Resolves a conflict with the CRM merge (#555) that landed on beta
between when this branch was cut and now.
Two conflict regions in backend/src/routes/adminCustomers.js:
1. **Require block** — both branches added new requires after
customerAccountsService. Kept both: this branch's
emailNormalization import AND beta's customerHoursService +
invoiceService imports (the CRM merge added the hours-billing +
invoice-creation paths to this router).
2. **Edit-customer validators** — both branches changed the same set
of body() validators in the PUT /:id handler. This branch added
the IDENTITY_PRESERVING_NORMALIZE_EMAIL options arg to
normalizeEmail; beta changed every body() to optional({ nullable:
true }) so passive-customer records that store nulls for missing
profile fields don't reject on save. Kept both: the nullable
pattern from beta + the email-normalization options from this
branch. Preserved beta's explanatory comment about the nullable
choice.
Also patched one NEW normalizeEmail site the CRM merge introduced:
- backend/src/routes/adminCustomers.js:231 — POST /admin/customers
now exists (CRM-era customer-create endpoint). Same options arg
applied.
backend/src/routes/adminBusinessProfile.js has an isEmail() WITHOUT
normalizeEmail() on the issuer email — intentional (no normalization
means no risk of the Gmail dot-strip bug for that field), no change
needed.
All 18 normalizeEmail sites now pass IDENTITY_PRESERVING_NORMALIZE_EMAIL.
7/7 regression tests still pass. Lint clean on the merged file.
When PR #555 (CRM module) added pdfkit/swissqrbill/pdf-lib/qrcode to
backend/package.json, every dev with an already-built dev image hit
a MODULE_NOT_FOUND restart loop on the next pull. Root cause: the dev
compose bakes node_modules into the image while live-mounting src/
from disk — a dep added on disk isn't visible to the running container
until the image is rebuilt.
The symptom doesn't point at the cause, so this adds a short rebuild
note to the Local Development section of CONTRIBUTING.md. A
self-healing entrypoint (compare node_modules/.package-lock.json
vs /app/package-lock.json on boot, npm ci if they differ) would fix
this at the runtime layer too; tracked as a follow-up.
The fix shipped in 3ab3756 added /backup to the boot-time chown list.
That broke installs that don't bind-mount ./backup:/backup — the
single greedy `chown -R /a /b /c /backup` returned non-zero on any
individual failure, exiting the script and putting the backend into
a restart loop.
Reverting to the upstream-stable version. The original EACCES at
backup time is better fixed by admins pointing the backup destination
at a writable path via the admin UI (e.g. /app/storage/backups,
which the script already chowns) rather than baking a /backup
assumption into every install's boot path.
The docker-compose `./backup:/backup` mount was the only bind mount
not included in wait-for-db.sh's startup chown step. On a fresh
install (or any time the mount point is recreated), it stays
owned by root, and the nodejs (UID 1001) process running the
backup service gets EACCES when trying to mkdir under /backup.
Added /backup to both the chown list (root branch) and the
writable-check list (compose `user:` override branch), each guarded
by `[ -d /backup ]` so installs that don't use the bind mount —
native deployments, k8s with a different backup destination, etc. —
still boot cleanly.
Existing installs hit by this need a one-time host-side
sudo chown -R 1001:1001 <host-mount-for-/backup>
because the on-disk ownership won't fix itself; the script only
chowns at startup, and the directory was already created with
the wrong ownership by Docker's mount-point auto-creation. From
this commit onward, fresh installs are correct from the first
boot.
PR #555 shipped the CRM module on beta. The README's "Beta Features
(Use at your own risk)" table is the right place to signal that the
feature exists, is opt-in, and carries non-trivial legal / financial
caveats — readers landing on the README should not first discover the
CRM by enabling its feature flags and bumping into the seeded
example contract bodies without warning.
Adds one row to the Beta Features table linking to
docs.picpeak.app/features/crm where the full disclaimers,
sub-feature pages, and admin-settings reference live.
CRM is intentionally NOT added to the top-of-README "Key Features"
list — those are stable, production-ready features. Mixing the beta
CRM in there would undermine the clear stable/beta distinction.
Line 205 of databaseBackup.test.js reassigned `fs.unlink` directly
(`fs.unlink = jest.fn(...)`), which permanently mutated the global
fs.promises module. Every test running after this in the same jest
worker process inherited the no-op stub, including
integration/storageBackend.test.js — whose LocalFsStorage.delete()
silently became a no-op, making the subsequent exists() assertion
flip from false to true.
Confirmed by adding a diagnostic patch to LocalFsStorage.delete:
post-await fsp.unlink, fs.existsSync(abs) returned true. unlink had
resolved without throwing but the file was still there → the unlink
was a mock.
Fix: jest.spyOn(fs, 'unlink').mockResolvedValue(undefined) + a
matching mockRestore() at the end of the test. Behaviour is
identical inside this test; the original fs.unlink is restored
when the test finishes, so subsequent tests get real fs.unlink
again.
Pre-existing issue — has been latent on upstream/beta forever.
Only surfaces consistently when CI load shifts jest's worker
allocation such that databaseBackup and storageBackend land in
the same worker process. This PR's extra integration test files
made that allocation deterministic locally and frequent enough on
CI to fail reliably.
The 5-minute session-sweep interval at sessionTimeout.js:17 fired at
module-load time without .unref(), so every jest worker that
transitively required this module (server.js → middleware → most
of the route layer) kept the event loop alive forever. The worker
then got force-killed on shutdown, surfacing as the longstanding
"worker failed to exit gracefully" warning at the end of every CI
run on upstream/beta.
Under enough I/O / memory pressure on a CI runner, the force-kill
could land MID-test rather than after the suite finished, taking
out whatever else was running on that worker — most visibly
integration/storageBackend.test.js on PR #555's runs.
.unref() makes the timer not keep the loop alive on its own.
Production behaviour is unchanged: the timer still fires every
5 min as long as anything else is holding the loop open (the HTTP
server, always).
CI's SQLite returned `[N]` (plain int) from `.insert().returning('id')`
while local SQLite returned `[{ id: N }]` (object form). The brittle
`const [{ id }] = ...` destructure crashed on the int shape. Switched
to the unwrap pattern used by the existing crmDb test harness so the
suite runs on both PG and every SQLite/knex combo the project supports.
Frontend half of the diagnostic shipped in 4812fcd. Adds:
- BackupIntegrityCard component — runs the check on demand, surfaces
the five summary counters (total / verifiedOk / existsButNoHash /
missing / hashMismatches), and expands collapsible result tables
for missing files + hash mismatches. existsButNoHash is exposed as
a separate amber-toned bucket so admins can distinguish hash-
verified evidence from existence-only at a glance — the latter is
explicitly weaker in a legal dispute and the UI says so.
- "Integrity" tab on BackupManagement, alongside the existing
Dashboard / Configuration / History / Restore tabs. Card is
portable — when the System Health page (backlog item) lands it
can lift the component without changes.
- Post-restore CTA on the RestoreWizard success card (D2 follow-
through): "Verify document integrity now" button that switches
the parent tab to Integrity. The audit trail captured at sign /
issue time is worth nothing if the documents it refers to are
missing from the restored copy — verifier surfaces that drift
in one click before the admin trusts the restored state.
i18n strings added in EN + DE (per user_languages — only those two
are native; other locales fall back to the English defaults and
should be flagged for native-speaker review per
feedback_translation_flagging if anyone picks them up).
Diagnostic for the bug fixed in a9280ea — confirms every *_path
column on quotes / contracts / invoices points at a file that
actually exists on disk and (where a *_sha256 column is set) the
file's bytes still hash to the expected value. Read-only;
on-demand only; no scheduler.
Per the design decisions locked in this PR's design call:
D1 — on-demand only for v1; scheduling deferred until we have
runtime data on large installs
D2 — not auto-triggered after restore; surface a "verify
integrity now" CTA on the restore-completed screen instead
D3 — wet-upload contracts hash-verified same as system-rendered
(signed_pdf_sha256 is computed at upload time, no special
case needed in the verifier)
Coverage (single source of truth in backupIntegrityService.CHECKS):
quotes.pdf_path existence
contracts.pdf_path + pdf_sha256 existence + hash
contracts.signed_pdf_path + signed_pdf_sha256 existence + hash
contracts.signed_customer_signature_path existence (PNG/JPG, no hash)
contracts.signed_admin_signature_path existence (PNG/JPG, no hash)
invoices.pdf_path existence
invoices.imported_pdf_path existence (admin-uploaded scans)
Report shape buckets each row into verifiedOk / missing /
hashMismatches / existsButNoHash so callers can distinguish hash-
verified from existence-only — the latter is weaker evidence in
a legal dispute and the UI should reflect that.
Route GET /api/admin/system-health/backup-integrity accepts an
optional ?scope= CSV filter (quote | contract | contract-signature
| invoice). Unknown scope tokens are rejected with a 400 +
BACKUP_INTEGRITY_UNKNOWN_SCOPE code rather than silently scanning
everything.
Frontend half (BackupIntegrityCard on a System Health page) is
deferred until backlog #11 (System Health page) is scaffolded.
The endpoint is independently useful via curl in the meantime.
backupService.getFilesToBackupInternal() enumerated a fixed list of
storage subdirectories (events/active, events/archived, thumbnails,
previews, heroes, uploads) and silently omitted the entire
business-docs/ tree. Every CRM PDF artefact and signature image fell
outside the in-app scheduled backup — restoring the DB without the
PDFs would have left every *_path column on quotes/contracts/invoices
as a broken FK and lost forensic evidence (the customer signature
PNG/JPG drawn on the public signing page is referenced by
contracts.signed_customer_signature_path; the rendered contract PDF
is referenced by signed_pdf_path with a stored signed_pdf_sha256
that would have nothing to verify against; wet-uploaded contracts
and admin-imported historical invoices are irrecoverable by design
since no renderer can reproduce them).
Single new scanDirectory call after the existing uploads scan,
covering:
- business-docs/quote/<year>/*.pdf
- business-docs/contract/<year>/*.pdf
- business-docs/contract/signatures/<contract_id>/*.{png,jpg}
- business-docs/invoice/<year>/*.pdf
- business-docs/invoice-imports/<year>/*.pdf
- and incidentally business-docs/dev-test/ (managed by adminDev.js,
bounded to 7 newest files, harmless to back up)
Verified that no migration is needed: hasFileChanged returns
!existing || checksum mismatch, so the first backup after this lands
flags every business-docs/** file as new and copies it. Restore path
in restoreService.performFilesRestore uses fs.mkdir({ recursive:
true }) on path.dirname(targetPath), so business-docs subdirectories
are recreated automatically from manifest entries — no restore-side
code change required.
Integration test pins the contract so a future refactor cannot
silently drop business-docs again.
The shell-script backup at scripts/backup.sh already covered all of
this via blanket `tar -czf storage`; only the in-app service was
affected.
Closes#574.
Reporter (@blazmaric) identified the root cause cleanly:
express-validator's `.normalizeEmail()` applies provider-specific
canonicalization by default — Gmail dot-stripping, +tag stripping,
googlemail → gmail folding, etc. That's wrong for identity: PicPeak
uses email as a login identifier, so `john.doe@gmail.com` getting
silently stored as `johndoe@gmail.com` means the user can't log in
with the address they were invited with.
The bug existed at 17 call sites across the codebase (auth, admin user
create/update, customer create/update, event create/update on three
different routes, customer login, feedback submission). All of them
are identity-bearing — none had a legitimate reason to strip dots
for deduplication.
Fix: introduce one shared options object in `utils/emailNormalization`
disabling every provider-specific normalization
(gmail_remove_dots, gmail_remove_subaddress,
gmail_convert_googlemaildotcom, outlookdotcom_remove_subaddress,
yahoo_remove_subaddress, icloud_remove_subaddress). The only default
left enabled is `all_lowercase`, which is safe — local-parts are
case-insensitive in practice on every major provider, and lowercasing
keeps login lookup consistent.
Every call site updated to pass the shared options. 7 unit tests pin
the preserved-dots, preserved-subaddress, preserved-googlemail-domain,
and still-lowercase behaviours so a future refactor can't silently
regress.
## Migration note
Existing accounts whose emails were already stripped before this fix
remain with the stripped form in the DB. The fix takes effect for new
invitations going forward. If an admin re-invites an existing user
with the un-stripped address, that would create a duplicate account —
out of scope here; if it becomes a real problem we can add a
backward-compat login fallback (try lookup with dot-stripped form too)
as a separate change.
Closes#567.
The sidebar already had a "vX.Y.Z available" indicator (#566 made it a
link to that release's page) but there was no way to read the actual
changelog inline or to grab a copy-paste upgrade command. This adds
the modal the issue spec'd, layered on top of the existing
updateCheckService / environmentService backend infrastructure that
already shipped.
## Backend
- `updateCheckService.fetchAvailableVersions` now returns full release
objects (tag, name, body, publishedAt, htmlUrl) instead of just
version strings — body data is what the changelog modal renders.
`checkForUpdates` extracts the version strings for its existing
consumers; no API change visible to callers.
- New `getReleasesSince(currentVersion, channel)` returns the list of
releases strictly newer than current, filtered to the user's
channel. Reuses the same 1-hour cache as `checkForUpdates` so the
modal opening doesn't trigger an extra GitHub round-trip.
- New `GET /admin/system/updates/changelog` route in `adminSystem.js`,
same auth + UPDATE_CHECK_ENABLED gating as the existing
/updates and /updates/instructions endpoints.
- 4 unit tests (axios mocked) pin: strictly-newer filtering,
channel-scoped, empty array on GitHub fetch failure, empty array
when already on latest.
## Frontend
- New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two
sections:
1. **How to upgrade** — fetches /updates/instructions for the
environment-detected copy-paste command (Docker compose / git /
standalone). Copy-to-clipboard button per step.
2. **Release notes** — fetches /updates/changelog for every
version between current and latest in the user's channel.
Latest is auto-expanded; older releases are collapsed by
default (click to expand). Each release also has a "View on
GitHub" link to the canonical release page.
- Renders release body markdown through the existing safe
MarkdownContent component (marked + DOMPurify allowlist).
- New `updateDismissal.ts` helper — single localStorage key holds the
last-dismissed version. Chip stays hidden until a STRICTLY newer
version appears, using the same compare semantics as the backend
(stable > beta, higher beta > lower beta, semantic numeric on
major.minor.patch). 9 unit tests pin the rules.
- `VersionInfo.tsx` — chip is now a button that opens the modal
instead of an external link (the #566 link-to-release behaviour is
preserved on the modal's per-release "View on GitHub" affordance).
Dismissal triggers an immediate re-render so the chip disappears
without waiting for the next route change.
No new dependencies — uses `marked` + `DOMPurify` that were already
present in the bundle for the contract block renderer.
Closes#565.
Beta has been the de-facto stable channel because the actual stable
lagged so far behind that new users following the README ended up
worse off than users who knew to switch to beta. The fix has two
parts: regular stable cuts (the PR #568 promotion is the first one)
and a written process so future cuts don't depend on memory.
This adds:
- RELEASING.md at the repo root — full operational doc with cadence
target (4–6 weeks), promotion criteria (CI green + 7-day bug soak
+ upgrade-walk on real-shaped data + operator smoke), the actual
beta→main mechanics including the conflict-resolution checklist we
used in PR #568, hotfix backport path (with PR #412 as the worked
example), and the project's versioning rules.
- CONTRIBUTING.md — replaces the four-line "Release Process" stub
(which was wrong; it described a hand-rolled flow that release-please
has handled for the last several releases) with a brief summary and
a pointer to RELEASING.md.
- README.md — one-sentence addition to the existing "Release Channels"
section pointing curious users at RELEASING.md.
No code change. CHANGELOG.md and version files are intentionally
untouched — release-please will catch this on the next regular cut.
Closes#566.
The admin sidebar showed the running frontend + backend versions as
plain text. Wraps each version (and the "update available" indicator)
in an anchor pointing at the corresponding GitHub release tag, opening
in a new tab so the admin session isn't disrupted.
A small githubReleaseUrl helper (extracted to its own module for
testability) does the version → URL mapping. Because release-please
tags every release as `vX.Y.Z[-beta.N]`, the version string already
carries the channel suffix and a pure template covers both stable and
beta without branching.
Three unit tests pin the URL template — stable, beta-with-suffix, and
a defensive check that the leading `v` isn't double-prefixed if a
caller accidentally passes a tag-shaped value.
Reviewer feedback on #555: nextQuoteNumber inside createQuote's
db.transaction was called without passing the outer trx, so
claimNextSequence opened its own connection — Postgres tolerated this
via the pool, SQLite (1-connection default) deadlocked on every quote
creation.
Audited the same pattern across invoiceService + contractService and
found five more matching call sites:
- createInvoice (single-row path after installment auto-route)
- spawnInstallmentInvoices (per-sibling claim inside the loop)
- createStorno
- createContract
- createFromQuote
All now thread trx through to nextXxxNumber → claimNextSequence so
the claim joins the caller's transaction on both engines.
convertToInvoiceOnly's Path B (standalone-contract) is the lone
remaining nextInvoiceNumber() call without trx — that path isn't
wrapped in a transaction at all (separate concern: sequence-number
leak on insert failure, tracked separately).
Previous fix (45f0606) papered over the bug by changing the German
wording from "innerhalb von {{minutes}} Minuten" to "bis {{at}}" —
that worked but changed the UX intent. The original German wording
("you have N minutes left") was deliberate and clearer than an
absolute clock time; the actual bug was that no caller ever computed
`minutes` from `responseLockedAt`.
Revert the DE translation to its original wording, then build a
{ at, minutes } object at the call site so EN ("until {{at}}") and
DE ("innerhalb von {{minutes}} Minuten") each pick up the variable
they need. `minutes` rounds UP so a 14m 32s remainder displays as
"15 Minuten" rather than promising 14 the customer can't actually
hit.
API-created events (and any event whose `color_theme` is NULL) had two
visible bugs in the admin edit page (#550 follow-up — PR #552 fixed the
v1 POST write path, this fixes the read/save path):
1. The theme picker initialised to the hardcoded `GALLERY_THEME_PRESETS
.default.config` ("Classic Grid", green) — which had nothing to do
with the admin's actual branding palette, while the gallery itself
was rendering with the branding theme. Confusing visual mismatch.
2. Saving the event for ANY reason (changing the date, password, etc.)
wrote `color_theme = 'default'` back to the row because the save
handler always emitted the picker's initial preset name. That
silently replaced "inherit from branding" with the literal Classic
Grid preset, so the gallery's visuals jumped.
Two fixes, both in EventDetailsPage:
- Add a `themeChanged` flag, defaulted false. Flip in the picker's
onChange / onPresetChange / onSyncFromBranding callbacks. The save
handler now only writes `updateData.color_theme` when the flag is
true, so saving without touching the picker preserves NULL.
- When `event.color_theme` is null and `publicSettings.theme_config`
(the site branding) is available, initialise `currentTheme` from
branding instead of the Classic Grid preset, with currentPresetName
set to 'custom' (since inherited branding isn't a named preset).
Falls back to the Classic Grid preset only when no branding theme
exists either.
Combined effect: opening an API-created event shows the same palette
the gallery uses, and saving without changing the theme preserves the
inheritance. Existing events with a stored color_theme are unaffected
(themeChanged stays false → no write, just like before for the
common no-change-to-theme save).
The German string used `{{minutes}}` while the call site at
QuoteResponsePage.tsx:300 passes `{ at: <localized time> }`, matching
the English string's `{{at}}`. Result on the public quote page when
the customer had already responded: the literal text "{{minutes}}"
rendered instead of the unlock time.
Switched the German wording to match the English semantics
("until X:XX") since the underlying value is an absolute time, not a
minutes-remaining count — the previous DE wording was also wrong about
WHAT the variable meant.
Extends #531 to the selection-based bulk-download flow. On iOS with a
selection at or under MAX_WEB_SHARE_FILES (25), galleryService
.downloadSelectedPhotos now routes through navigator.share({ files })
so the photos land directly in Photos via the share sheet's "Save N
Images" action. Above the cap, anywhere off-iOS, or on any failure,
the existing server-side zip path runs unchanged.
The 25-file cap is the empirically-safe ceiling: iOS Safari's share
sheet starts choking beyond ~25–30 files, and every File materialises
as an in-memory Blob before share() is invoked, so a 500-photo
selection would buffer multiple GB on the device.
trySaveMultipleToDevice exposes three outcomes:
- 'shared' — share() resolved; flow ends
- 'dismissed' — user cancelled (AbortError); flow ends without zip
fallback so dismissal isn't silently overridden
- 'fallback' — capability missing or unexpected failure; caller
takes the zip path
Partial shares are deliberately avoided: a single failed photo fetch
collapses the whole selection back to the zip endpoint rather than
sharing only the photos that resolved.
All 4 grid callers (PhotoGrid, PhotoGridWithLayouts, GalleryStoryLayout,
GalleryPremiumLayout) funnel through downloadSelectedPhotos, so no
caller-side changes are needed. Android, desktop, Firefox, and
"Download All" are untouched.
Layers on top of #556 (iOS-only gating via isIOS()). Builds against the
fix/android-download-web-share-554 branch.
The CRM template seeders (crmEmailTemplates / contractEmailTemplates /
eventReminderTemplates) were idempotent and ready, but only
contractEmailTemplates was actually called (lazily, by contractService
sends). crmEmailTemplates had no caller anywhere — every install that
didn't pre-exist its templates failed every quote_sent / invoice_sent /
storno_issued / invoice_reminder_* send with "Email template '<key>'
not found". The queue processor retries 3 times then leaves the row
in status='pending', retry_count=3, silently dead with no admin
surface (see project_crm_backlog for the eventual System Health page).
Fix: wire all three seeders into server.js startServer() right before
startEmailQueueProcessor. The new _emailTemplateBoot.js orchestrates
all three and then, for any template_key it just inserted, resets
retry_count on stuck email_queue rows of that email_type so the
queue processor's next tick picks them back up. Recovery is targeted:
unrelated retry-exhausted rows (e.g. SMTP-timeout failures) are not
touched.
Integration test boots a fresh CRM DB, pre-seeds a stuck quote_sent
row plus an unrelated stuck row, runs the boot helper, and asserts:
templates landed, stuck quote_sent row was reset, unrelated row was
left alone.
Already-deployed installs heal automatically on the next backend
restart after this lands.
Wires customer_accounts.billing_email into the invoice, Storno, and
payment-reminder send paths. Previously the column existed on the
schema and the customer-detail page rendered an input for it, but no
send path read it — every outbound email landed on customer_accounts.email
regardless. That mismatch is the failure mode flagged in
feedback_data_driven_completeness: a UI field that promises behavior
the backend silently doesn't deliver.
Routing matrix:
- invoice / Storno / payment reminder
To: billing_email (fallback email when unset)
CC: email (when billing_email took the To slot) + per-doc cc_pdf_email
- quote / contract / event reminder / gallery share
To: email (unchanged — decision-maker address)
- payment-check / paid-notification
To: admin contact (unchanged — internal flow)
A new resolveBillingRecipients helper centralises the rules:
prefer billing_email, dedupe addresses case-insensitively, keep
per-doc cc_pdf_email as a supplemental CC. Lives in its own file
(_billingRecipients.js) to match the _renderContext.js convention.
`savePhotoToDevice` previously buffered the full image through JS as a
Blob on every platform before clicking <a download>. On cellular this
added ~5s of dead air between the button press and the browser's
download dialog, prompting users to re-click and produce duplicate
downloads (#554 follow-up, post-#556).
The blob round-trip is only required for the iOS Web Share path
(`navigator.share({files})` needs File objects in hand). On Android and
desktop the browser can fetch the download URL itself and show its own
progress in the notification shade — instantly. So iOS keeps the
existing flow; everywhere else gets a direct anchor navigation.
The new `triggerDirectDownload` helper uses `api.getUri()` so the path
also works in split-origin deployments (where the existing hardcoded
`/api/...` pattern used by `downloadAllPhotos` would 404).
Tests updated: Android / desktop / regular-Mac branches now assert that
`fetchPhotoBlob` is NOT called and `triggerDirectDownload` is invoked
with a `/gallery/{slug}/download/{id}` URL. iOS tests unchanged.
Drops the isInt({ min: 0 }) constraint on lineItems.*.unitPriceMinor
in both the adminInvoices and adminQuotes POST/PUT validators so
admins can add Treuerabatt / Frühbucherrabatt rows as standalone
negative-priced lines (matches standard DE/CH invoice practice).
A service-layer guard rejects saves whose computed total goes below
zero (INVOICE_TOTAL_NEGATIVE / QUOTE_TOTAL_NEGATIVE, both 400) so a
mis-typed discount can't accidentally mint a credit-balance invoice
that would masquerade as a regular row in dashboards. Credit notes
still belong in the Storno path (createStorno), which is unchanged.
Quote-side integration coverage is omitted for now — createQuote's
cold-require path takes ~30s under the test harness; the invoice
test exercises the same validator + guard shape.
PR #531 routed the single-photo download through navigator.share()
whenever canShare({files}) returned true, on the assumption that any
mobile share sheet would expose a "Save Image" action. That holds on
iOS — Safari's share sheet has a first-party "Save to Photos" entry —
but on Android the system share sheet only lists installed apps that
registered an image/* intent (WhatsApp, Telegram, Drive, etc.). There
is no built-in save-to-Gallery action, so Android users tapping the
download button got an app-picker instead of the file saved to their
device.
Fix: gate the Web Share branch behind a UA-based isIOS() check. Android,
desktop, and everything else fall through to the existing <a download>
path (file lands in Downloads, visible in the Photos / Gallery app
afterwards — same behaviour as before #531). iOS — including iPadOS
13+, which reports as MacIntel + touch — keeps the share-sheet flow
that drops directly into Photos.
UA-sniff is the only available signal here: canShare({files}) is true
on both iOS Safari and Chrome Android, so feature detection cannot
distinguish them.
Tests pin all six scenarios — iOS share path, Android download fallback
(even with canShare=true), desktop, iPadOS-as-Mac detected as iOS, regular
Mac NOT detected as iOS, AbortError dismissal preserved (no surprise
fallback), and non-Abort share() rejection falls back to download.
The pre-event reminder feature shipped with 5 seeded templates
(event_reminder_default + wedding/birthday/corporate/other) but the
CRM → Development "Send any CRM email to me" picker only listed the
quote/invoice/contract templates. Maintainer can now eyeball each
reminder category's body without staging a real event.
Backend:
- Extend TEMPLATES_KEYS in adminDev.js with all 5 reminder keys.
- Add event_date (today+2d), days_before (2), business_name (from
business_profile.legal_name) to the common payload so the
{{tokens}} in the reminder bodies resolve.
Frontend:
- Extend CrmEmailTemplateKey union.
- Add TEMPLATE_LABEL_KEYS entries.
- EN+DE i18n labels under crmDev.templates.label.event_reminder_*.
No PDF attachment — reminders are body-only emails (matches the
real flow).
The full reminder-email implementation (eventReminderService,
eventReminderTemplates self-heal, ReminderTemplatesPage,
EventReminderOverrideCard) shipped in the CRM bundle but the
FeaturesTab card kept lockedReason=NOT_YET_AVAILABLE — so the
working feature was invisible.
Flip the card to the same shape as customerPortal: status="beta",
real setFlag handler, no disabled/lockedReason. The sub-tab in
Settings → Reminder templates already self-mounts when the flag
is on, and the per-event override card already self-renders on
the event detail page.
Description copy + EN/DE i18n updated to describe what the feature
actually does (per-category pre-event nudge) instead of the old
"coming soon" placeholder.
Adds two pieces:
- __tests__/integration/helpers/crmDb.js — boots a temp-SQLite test
DB by invoking every migrations/core/*.up() directly. Bypasses
knex's Migrator because its exclusive write lock deadlocks
001_init's nested initializeDatabase() call. ~1 second cold start.
- __tests__/integration/crmSchema.test.js — 36 assertions on the
table + column layout after the consolidated CRM migration runs.
Pins:
- every CRM table present (quotes, contracts, invoices + the
eight supporting tables)
- deal_uuid columns on all three lineage tables (the column
DocumentLineageCard joins on — drop it anywhere and the card
silently returns partial data)
- back-pointer FKs (converted_contract_id, source_contract_id,
source_quote_id) — the exact columns that triggered the
Postgres FK-ordering bug fixed earlier in this PR
- Storno discriminator (kind, cancels_invoice_id, replaces_
invoice_id) per feedback_storno_filter_everywhere
- event time columns from migration 137
A full quote→contract→invoice lineage walk is deferred — quote
service's nextQuoteNumber() opens an inner transaction from inside
the createQuote outer transaction, which deadlocks SQLite's default
1-connection pool. Postgres dev DBs never see it. Either fix the
service to thread trx through, or run lineage tests against a real
Postgres in CI (mirror schema-drift.yml). Filed as separate work.
The suites already existed (538 backend tests, 40 frontend tests, with
solid CRM coverage on quoteService/contractService/invoiceService/
customerHoursService/eventService.calendar) but no CI workflow invoked
them. Wire both into a single Tests workflow that triggers on any push
or PR to main/beta.
Six backend suites are excluded — they fail on upstream/beta too
(supertest fixture + knex mock chain issues unrelated to CRM). The
explicit ignore pattern keeps the workflow green on day 1; each
excluded suite is listed inline as test-infra debt to fix individually.
Backend job pins SKIP_S3_TESTS=true (the same default the test setup
file applies) so the backup-service integration doesn't try a real S3
round-trip when no MinIO is provisioned.
Two upstream tests regressed because the CRM PR added expected behavior
they didn't anticipate:
- galleryOgService.shareImage.test.js: formatEventDate is now async and
routes through utils/dateFormatter so the OG card respects the admin's
general_date_format setting (per feedback_respect_general_format_settings).
That adds a third db('app_settings') call on every buildOgMetadata path.
Mock the formatter module directly — the format itself is irrelevant
to the cover-vs-logo contract this file pins.
- customerAccountsService.test.js: createInvitation now allows a duplicate
email when the existing row is PASSIVE (password_hash IS NULL) — that's
the "promote passive customer to portal" path. The active-customer
rejection mock now has to set password_hash so the guard fires.
Both are test-only changes; no service code touched.
quotes.converted_contract_id and invoices.source_contract_id were
declared with inline FKs to contracts(id), but contracts is created
later in the same migration. SQLite accepted the forward reference;
Postgres rejected it ("relation \"contracts\" does not exist"), which
broke the Schema drift (#530) workflow and any fresh Postgres install.
Same pattern as events.hero_photo_id → photos.id in db.js: declare the
column without a constraint, then add the FK in a separate alterTable
after both sides exist. Wrapped in try/catch so re-runs against a DB
that already has the constraint are a no-op.
Verified locally against the #530 recovery scenario (initializeDatabase
then migrate:safe) and the fresh-install path: both converge cleanly,
both FKs land on the expected tables.
Adds a top-level disclaimer section to README + a dedicated
docs/crm-disclaimers.md spelling out two areas where picpeak ships
defaults the operator MUST review before going live:
1. Contract blocks (image rights, NDA, model release, cancellation,
jurisdiction, …) — written by the maintainer, NOT by a lawyer.
Every operator must have their lawyer review and adapt them
before sending any contract to a customer.
2. QR-bills and SEPA EPC payloads — rendered from the data the
operator typed. Picpeak is open source; we recommend scanning a
test invoice with the operator's bank app to verify the QR
actually works.
Matches the on-screen amber disclaimers already shown on the
Contract Block Library page and the Business Profile payment-block
editor.
~940 new keys per primary locale covering every CRM surface:
quote / invoice / contract editor + list + detail + public response
pages, calendar, hours, tax report, deals lineage, reminder emails,
feature toggles, settings tabs, error toasts.
en.json + de.json are hand-translated by the maintainer and are
authoritative. fr / nl / pt / ru received the same key set but
machine-derived strings — flagged for native review in the PR
description per project policy (see memory feedback_translation_flagging).
3-way merge note: 1 conflict (fr.json) hand-resolved to keep
upstream's improved phrasing for previewLayout / livePreview /
heroPlaceholderText alongside feat/crm's pdfTypography keys.
POST /v1/events was a strict subset of the admin create path: it did not
accept color_theme on the body, and it skipped the event_feedback_settings
insert that adminEvents.js does. Two visible bugs followed.
1. Editing an API-created event in the admin UI snapped the theme picker
to GALLERY_THEME_PRESETS.default (EventDetailsPage.tsx falls through to
the default preset when event.color_theme is falsy), and saving wrote
that default back. Inherited themes were silently clobbered.
2. The "Enable Guest Feedback by default" admin setting (#520) did not
apply to API-created events. With no event_feedback_settings row the
gallery UI reads feedback as off, regardless of
event_default_feedback_enabled.
Fix mirrors the admin path:
- color_theme accepted on the request body (optional, persisted as-is —
preset name or JSON-encoded ThemeConfig, same shape adminEvents
stores).
- feedback_enabled accepted on the request body; when omitted, falls
back to the event_default_feedback_enabled global setting (same
behaviour adminEvents.js:511-520 implements via readBooleanSetting).
- event_feedback_settings row inserted when feedback resolves to true,
using the same sub-flag defaults as the admin form (everything on
except require_name_email).
OpenAPI JSDoc updated so docs.picpeak.app picks up the new fields.
Tests cover all four scenarios — explicit color_theme persisted, JSON
theme persisted verbatim, explicit feedback_enabled creates the row,
omitted feedback_enabled honours the global setting, and a validator
regression for non-boolean feedback_enabled.
When PicPeak runs behind NPM / Traefik / Caddy, the inner nginx receives
plain HTTP from the outer proxy. The previous `X-Forwarded-Proto $scheme`
therefore always forwarded "http" to the backend, even when the public URL
was HTTPS. Express has `trust proxy` enabled for loopback/linklocal, so
req.secure became false, the Secure cookie flag wasn't set, and generated
URLs (cookies, tokens) used http://.
Add a top-of-file `map` block that picks the incoming X-Forwarded-Proto
when present and falls back to `$scheme` for direct access. Applied to both
nginx.conf (bundled production image) and nginx.dev.conf.
Validated with `nginx -t` against nginx:1.28-alpine (the same image used
by Dockerfile.prod / Dockerfile).
- frontend: `npm test` now runs all 7 vitest suites instead of one hardcoded
file; the previously skipped ProtectedImage / Skeleton / usePublicSettings /
contrast / themeMigration / url suites are now active in CI
- frontend: wrap ThemeCustomizerEnhanced test in QueryClientProvider so the
newly-enabled run passes (component uses useQuery internally)
- root: drop unused better-sqlite3 / canvas / node-fetch + their
prebuild-install/tar-fs override (backend keeps its own copies); add dotenv
so playwright.config.ts can load on a clean install; add name/version/private
- LegalPage: scheme-validate external_url before window.location.replace so a
CMS edit can't redirect visitors to javascript:/data:
- LegalPage: force rel="noopener noreferrer" on target="_blank" anchors in
sanitized CMS HTML to block reverse-tabnabbing
@Rekoo-PS confirmed the prior #521 fix landed on beta but reported the
preview still shows the default "PicPeak" title — their brand is
"arkan-studio". Root cause: that fix used Vite's build-time
%VITE_DEFAULT_TITLE% substitution. Self-hosters running the pre-built
ghcr.io/the-luap/picpeak/frontend image can't override at build time
without rebuilding, so they were stuck with whatever the upstream
build baked in.
Pivot to runtime substitution: the frontend container now reads
BRAND_TITLE / BRAND_DESCRIPTION env vars on startup and envsubsts
them into index.html. Change the values in .env, restart the frontend
service, done — no rebuild required.
Mechanics:
- frontend/index.html: tokens are now ${BRAND_TITLE} /
${BRAND_DESCRIPTION} (shell expansion syntax, passes through Vite
unchanged into the built dist).
- frontend/Dockerfile: install gettext (provides envsubst), snapshot
/usr/share/nginx/html/index.html → index.html.tpl at build, install
docker-entrypoint.sh, wire ENTRYPOINT to it. The .tpl is the
immutable source — every container start re-renders index.html
from .tpl, so restarts pick up new env values cleanly (no
accidental "first-boot env stuck forever" trap).
- frontend/docker-entrypoint.sh: applies defaults if env unset,
runs envsubst (locked to BRAND_TITLE + BRAND_DESCRIPTION
explicitly so /assets/*.js template literals aren't touched if
anyone ever extends substitution to the bundle), execs nginx.
- frontend/vite.config.ts: drop the htmlTitleDefaults plugin — no
longer needed since substitution is fully runtime.
- frontend/.env.example + .env.production.example: drop the
VITE_DEFAULT_* docs (the vars no longer have effect).
- docker-compose.yml + docker-compose.production.yml: pass
BRAND_TITLE / BRAND_DESCRIPTION env into the frontend service
with sensible defaults so unconfigured installs work unchanged.
- .env.example: add BRAND_TITLE / BRAND_DESCRIPTION with comment
pointing at the social-preview use case.
Verified end-to-end against the built image:
- BRAND_TITLE="Arkan Studio" BRAND_DESCRIPTION="Wedding photographs
by Arkan Studio" → index.html serves <title>Arkan Studio</title>
+ og:title="Arkan Studio" + og:description correctly substituted.
- .tpl preserves ${...} tokens so the next restart can re-substitute.
- Bundle assets unaffected.
- Defaults applied when env unset → <title>PicPeak</title>.
Docs PR in picpeak-docs describes the two new env vars under
"Social link preview fallback" in the environment-variables reference.
Refs: #521
@Rekoo-PS reported zoom on mobile only shows the centre crop — the
image zooms but you can't pan around to see other parts. Single-finger
touch was being routed through the carousel-swipe branch which is
gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed
the touch hit no handler at all.
Desktop has the equivalent path via handleMouseDown / handleMouseMove
(line 364), which is why this only manifests on mobile.
Add a single-finger pan branch to the touch handlers that mirrors the
mouse path:
- handleTouchStart: when zoom > 1 and one finger, record dragStart
relative to the existing dragOffset (so subsequent moves continue
from where the last pan left off, not from origin).
- handleTouchMove: when isDragging + zoom > 1 + one finger, update
dragOffset from touch position.
- handleTouchEnd: clear the isDragging flag (offset persists so the
image stays where the user left it).
Also fix a latent bug surfaced while reading the pinch-zoom path:
when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the
photo sat off-centre at natural zoom. Re-centre in handleTouchMove
when newZoom drops to <=1 with a non-zero offset.
Carousel swipe stays disabled when zoomed (existing behaviour). Mouse
path untouched. Pinch-to-zoom path untouched.
Refs: #532
@Jasper2213 reported non-technical clients struggle to get downloaded
photos into their Photos / Gallery app — current flow goes through the
Files folder, requires unzipping for the bulk download, and is hard to
explain over email. Browsers can't write directly to the OS Photos app
(it's a protected location), but navigator.share({ files: [...] }) opens
the native share sheet which on iOS includes "Save Image" and on Android
includes "Save to Photos" / "Save image" — exactly the affordance non-
technical users are looking for.
Plumbed through three layers:
1. galleryService — new savePhotoToDevice(slug, photoId, filename).
Fetches the photo blob, probes navigator.canShare({ files: [file] })
with a representative File (some browsers return true for empty
files arrays even when they won't accept a non-empty one), and:
- shares if supported,
- falls back to the existing <a download> path otherwise.
AbortError on share() means the user dismissed the sheet — that's
a choice, not a failure, so no fallback. Any other error falls
through to a regular download so the user still gets the file.
Refactored the existing downloadPhoto to share the fetch + trigger
helpers (no behaviour change for the other 3 callers; they keep
the regular download path).
2. useGallery — new useSavePhotoToDevice() hook next to the existing
useDownloadPhoto(). Onsuccess toast omitted because the share-sheet
path doesn't finish from this code's perspective — the OS UI takes
over and the user picks the destination, so "Photo downloaded" is
misleading. Fallback path stays silent to keep the two flows
symmetrical (the file appearing in Downloads is its own signal).
3. PhotoLightbox — swap the existing useDownloadPhoto call site to
useSavePhotoToDevice. No UI change. Desktop unchanged. Other
download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView
bulk) still use useDownloadPhoto — scoping this PR to the
lightbox download button per the discussion thread.
Browser support:
- iOS Safari 15+: Web Share Files → "Save Image" → Photos ✓
- Chrome Android: Web Share Files → "Save to Photos" / "Save" ✓
- Desktop Chrome: canShare returns false → regular download ✓
- Desktop Safari: canShare returns false → regular download ✓
- Firefox (any): no Web Share File support → regular download ✓
No new tests — the flow is browser-API-driven; jsdom doesn't model
navigator.share or canShare, so a meaningful unit test would mostly
exercise the mock rather than the contract. Verified the build is
clean (tsc --noEmit + vite build both pass).
Refs: #531
@Tietge86 spotted that both branches of the heart-icon className were
`text-white` — the conditional was a no-op, the `fill-current` class
that would actually fill the icon was missing entirely. The button
background was turning red on like, but the heart icon stayed as a
white outline against the red, making it nearly invisible.
Move text-white outside the conditional (always white against the
red/dark backgrounds the button uses), and add fill-current to the
liked branch so the heart fills in.
Same shape as bug 2 of the original report — the like state needed to
be visually unambiguous. PhotoLikes.tsx was already fixed in this PR;
this catches the equivalent latent bug in the inline lightbox toolbar
button.
Also: bug 4 of the original report (recovery flow) turned out to be
SMTP misconfig on the reporter's end (mailhog silently dropping
emails), not a PicPeak bug. Confirmed in this thread; no further
backend changes needed.
Refs: #538
Picks up three of the four bugs from @Tietge86's report. Bug 4
(recovery flow) needs network-tab data from the reporter before a fix
makes sense; commented on the issue asking for the HTTP status from
POST /gallery/:slug/guest/recover.
Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx)
The feedback filter was scoping by `photo.like_count > 0`, which is
the global aggregate across all guests. In guest identity mode the
filter intent is "show MY picks", so a guest who'd liked photos that
nobody else had touched got an empty grid.
Fix: pull the current guest's interactions from /my-feedback (already
keyed by x-guest-token in the api interceptor) into per-type
photo-id Sets and filter against those when identity_mode === 'guest'.
Falls back to the aggregate-count check in simple mode where there's
no per-person identity to scope by. Same per-guest scoping applied to
the chip-count labels ("Liked (N)" etc.) so the chip number matches
what the filter actually surfaces — otherwise the chip says one
count globally and the filter shows a different (smaller) one, which
is the same UX cliff #538 originally surfaced.
The /my-feedback query is gated on isGuestIdentityMode (not on
filterType being feedback-related) so the chip counts are populated
on first render. One extra request per gallery load in guest mode;
payload is tiny.
Bug 2 — Liked state on PhotoLikes button invisible
bg-red-50 text-red-600 is barely visible against most themes,
especially dark + brand-coloured backgrounds. Switch to the same
filled state the lightbox toolbar already uses
(bg-red-500/80 text-white) so the like registers visually.
Heart icon's fill-current was already there for the liked state —
unchanged.
Bug 3 — Aggregate like count leaks in lightbox toolbar
PhotoLightbox.tsx rendered {likeCount} unconditionally next to the
inline heart button. When the admin has show_feedback_to_guests off,
guests still saw how many other guests had liked a photo (the count
is an admin-only metric in that mode). Gate the span on
feedbackSettings?.show_feedback_to_guests, matching how the rest of
the lightbox toolbar treats that toggle. Also added show_feedback_to_guests
to the local feedbackSettings TS type (backend already returns it).
Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow
plumbing best validated via manual / e2e (the existing my-feedback
endpoint and feedbackService are already covered upstream); bugs 2/3
are CSS and a conditional render.
Refs: #538 (bugs 1, 2, 3 of 4)
First CI run failed at the precondition check because the SQL `CASE WHEN
to_regclass(...) IS NULL THEN 0 ELSE (SELECT count(*) FROM migrations)`
expression doesn't short-circuit at parse time — Postgres parses the
subquery against `migrations` even when the outer guard would skip it,
fails the run with "relation 'migrations' does not exist".
initializeDatabase() doesn't create the `migrations` tracking table —
that's the migrate:safe runner's responsibility — so in the recovery
scenario the table genuinely doesn't exist yet. Both "absent table" and
"present but empty table" are valid recovery states.
Split the check into two shell steps: to_regclass first, then count only
if the table exists. Avoids the parse-time subquery error and accepts
either state.
Refined from the original #530 framing after a dry-run uncovered that the
"bootstrap vs migration chain" diff produces mostly noise — most of the
~200 lines of difference are expected (migrations add new tables and
columns over time). initializeDatabase() isn't a parallel path that
diverges from migrations; it's invoked by migration 001 itself, so every
normal install/upgrade runs both.
The genuine drift hazard surfaced during the dry-run: a DB with the
modern bootstrap tables but an empty `migrations` table (which happens
when a backup was restored that lost the migrations table, or someone
invoked initializeDatabase() outside the runner, or the DB was moved
between systems without copying the migrations row) fails to upgrade.
Failure mode:
1. detectExistingSchema sees the bootstrap tables + empty migrations,
treats it as an "existing deployment".
2. Runs the legacy chain first.
3. legacy/008 renames email_templates.subject → subject_en.
4. core/029 (later in the chain) inserts email templates referencing
the pre-rename `subject` column.
5. Postgres rejects: column "subject" doesn't exist; subject_en is
NOT NULL with no default.
Fresh installs avoid this because they only run core/* (and core/059
handles the rename AFTER core/029 has inserted). Real legacy upgrades
avoid it because their migrations table already records legacy/008–028
as applied historically.
Fix in detectExistingSchema:
- Detect the modern bootstrap fingerprint (photo_categories + cms_pages
both present, which initializeDatabase produces as part of the
consolidated post-004-era bootstrap).
- When matched, enumerate every file in migrations/legacy/ and mark
each as applied. This puts the recovery state on the same code path
fresh installs use — only core migrations run, in core order.
- Real legacy upgrades that already have entries in the migrations
table hit no-op markings (markMigrationAsApplied skips duplicates),
so their behaviour is unchanged.
New CI workflow (`.github/workflows/schema-drift.yml`):
- Boots fresh postgres.
- Seeds via `node -e \"require('./src/database/db').initializeDatabase()\"`
— reproduces the recovery state in one line.
- Runs `npm run migrate:safe`.
- Asserts: precondition (bootstrap fingerprint + empty migrations
table), migrate:safe exits 0, final schema has ≥40 tables (soft floor,
not exact pin so future migrations don't force workflow edits),
legacy migrations marked applied (confirms the fingerprint check
actually fired vs. the chain silently bailing).
- Triggers only on PRs that touch backend/migrations/**,
src/database/db.js, knexfile.js, or this workflow.
Manually verified end-to-end before this commit:
Before fix: migrate:safe dies at core/029 with NOT NULL violation
on email_templates.subject_en (17/48 tables present).
After fix: 82 migrations applied + 27 marked applied = 109 total,
final state has all 48 tables matching fresh-install.
Issue body in #530 has been updated to match this refined scope.
Refs: #530, #484, #519
Folds all three follow-up items tracked in #525 into one commit:
1. Mirror PR #500's category scoping on adminPhotos.js. The admin
upload route at adminPhotos.js:231 still accepted any category_id
without event scoping — quietly less strict than the public v1
API after #500 landed. Same one-liner fix (event_id OR is_global)
with a matching 400 response shape so admin + v1 stay consistent.
2. Extract a shared slugify() in backend/src/utils/slug.js with the
NFD-strip-combining-marks fix from #502, and route 5 callers
through it:
- adminEvents.js (event-name slug)
- events.js (event-create slug)
- v1/events.js (replaces local slugify helper)
- adminArchives.js (archive→category slug)
For pure-ASCII input the output is byte-identical to each old
inline pipeline, so existing slugs in the DB keep round-tripping
cleanly via lookup. Accented inputs now transliterate (Família
→ familia) instead of dropping the diacritic (Família → f-mlia).
adminCategories.js stays with its own pipeline (underscores-as-
word-chars semantics differ from the events-style transform —
changing would silently shift wedding_party → wedding-party on
new inserts). xmpGenerator.sanitizeKeyword stays unchanged for
the same compat-cautious reason.
3. Cover the v1 upload happy path. Existing test only exercised the
400-out-of-scope branch. Add two happy-path cases that stub
sharp / generateThumbnail / storage.putFromFile and pin the
response shape (id, category_id, type, etc.) plus the collage-
slug → type='collage' flip. Temp file recreated in beforeEach
because the handler unlinks it on success.
Tests:
- New slug.test.js: 22 cases pinning ASCII parity with the legacy
pipeline (so the refactor is provably non-breaking for existing
data) and the corrected accent handling across de/es/fr/nl/pt
inputs, plus CJK and edge-case behaviour.
- events.category.test.js: 4 tests total (2 existing + 2 new happy
path).
- galleryOgService.shareImage.test.js: 11 (3 added in #521 + 8 pre-
existing) still pass.
37 tests pass across the three touched files.
Refs: #525, follows up #500 and #502
@Rekoo-PS reported the LanguageSelector pushing into the company-name
title on narrow viewports — the button always rendered
Globe + flag + full language name (~120px), and on mobile that pinched
the left-side title cluster in AdminHeader.
Wrap the name in `hidden sm:inline` so <sm the button collapses to
just Globe + flag, matching the existing "hidden xl:block" pattern
on the date display in the same header. Self-explanatory at icon-only
width (users see their current flag and a globe), and the dropdown
still shows full names when opened. Title/aria-label keep the name
discoverable for screen readers + tooltip hover on the icon-only state.
Refs: #523
@Rekoo-PS reported that gallery URLs sent via the WhatsApp Business
API render an unbranded "PicPeak - Photo Sharing Platform" preview
even though manual link sends from the WhatsApp app pick up the
per-event rich preview correctly. Two root causes, two fixes:
1. WhatsApp Business and 3rd-party preview services (Twilio,
LinkPreview.net, etc.) don't always crawl with the recognisable
"WhatsApp/X.Y.Z" UA we matched in nginx + galleryOgService.
Extend the regex (both copies) to also catch WhatsAppBot, wa-bot,
LinkPreview, and Slack-ImgProxy.
2. Even with broader UA coverage, some senders cache metadata with
no UA at all and fetch the static SPA shell. That shell's
<title> was hard-coded to "PicPeak - Photo Sharing Platform" —
embarrassingly generic for any self-hosted brand. Switch to
Vite's %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION% HTML
substitution so self-hosters can bake their brand into the
fallback at build time. Defaults stay "PicPeak" so the upstream
image doesn't change behaviour for anyone.
The per-event rich preview path (handleGalleryOgRequest, fired on
matched crawler UAs) is unchanged — this only improves the fallback
for unrecognised UAs and for the SPA-shell title that humans see in
their browser tab.
Adds a vite.config plugin to provide the defaults when env vars
aren't set, so unsubstituted "%VITE_..." literals never reach the
built HTML. Adds .env.example entries explaining the override.
Tests: extend galleryOgService.shareImage.test.js with an
isSocialCrawler suite that pins every documented UA (incl. the new
ones) plus three browser UAs (negative) and null/empty edge cases.
Verified locally: `vite build` with VITE_DEFAULT_TITLE="MyBrand"
produces <title>MyBrand</title> + og:title="MyBrand"; without the
env var falls back to "PicPeak".
Refs: #521
@Rekoo-PS asked for an admin-level switch so new events can have Guest
Feedback enabled out of the box instead of toggling it on every time.
Mirrors the existing event_default_require_password pattern (#317) —
same shape end-to-end, same set of five files.
- publicSettings.js: whitelist + expose event_default_feedback_enabled
(defaults to false to match the prior hard-coded form default; no
behaviour change for existing installs until an admin flips it).
- adminEvents.js: rename `feedback_enabled = false` destructure to
`feedback_enabled: feedbackEnabledInput` so we can distinguish
"omitted" from "explicit false", then resolve the default from the
setting only when the caller omitted it — identical to the
require_password handling a few lines above.
- Frontend EventSettings type + state + loader: new boolean,
default false.
- EventsTab: toggle UI right under "Require password by default".
- CreateEventPage: one-shot useEffect that seeds
feedback_settings.feedback_enabled from the public setting on first
load (mirrors the require_password seed effect right above it).
Sub-toggles (likes / ratings / comments) keep their hard-coded
true defaults so flipping the master setting immediately gives
sensible behaviour without a second admin setting to manage.
Refs: #520
@Rekoo-PS reported the MessageSquare comment button stayed visible in
the lightbox toolbar even when guest comments were disabled. Same
class of bug as #513 (per-photo Like button missing the master
gate) but on a different control.
The Like and Rating buttons in the lightbox toolbar gate correctly:
feedbackEnabled && feedbackSettings?.allow_likes
feedbackEnabled && feedbackSettings?.allow_ratings
The MessageSquare button only checked feedbackEnabled. Since likes
and ratings already have their own inline buttons in the same
toolbar, this third button is effectively the "open comments panel"
affordance — its badge counts comments, its tooltip mentions
comments. When comments are off it has nothing meaningful to do.
Add allow_comments to the local feedbackSettings type (the backend
already returns it via galleryFeedback.js:33) and gate the button on
feedbackEnabled && feedbackSettings?.allow_comments.
Refs: #518
Alpine ships BusyBox ps (no -p PID, no pgrep), which failed CI on the
first run of this workflow with "ps: unrecognized option: p". Replace
the pgrep-then-ps chain with `ps -o user,comm | awk '$2=="node"'`
which works on both BusyBox (Alpine, in the container) and procps
(the GitHub runner host, though we don't use it here).
Unit test for the v1 upload route's category lookup, requested in
the PR review. Mocks db (chainable, mirroring src/routes/__tests__/
adminAuth.test.js) plus apiTokenAuth/requireApiScope (pass-through)
and multer (stub req.file). Two cases:
1. The scoping clause: the andWhere callback applied to a knex
builder spy produces .where({event_id: <event.id>}).orWhere(
'is_global', true) — exactly the contract the reviewer asked
for, exercising the OR-clause rather than just asserting the
callback was passed.
2. Null lookup result yields 400 with "Unknown or out-of-scope
category_id <N>".
No v1 jest scaffolding existed before, but the project-wide harness
(backend/jest.config.js + jest.setup.js) already covers the new
file via testMatch '**/__tests__/**/*.test.js'. Happy-path tests
deferred — would require stubbing fs/sharp/imageProcessor/share
linkService and several more db chains, which the reviewer was
willing to accept as a separate follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR review pointed out the original lookup
db('photo_categories').where({ id: parsedCategoryId }).first()
accepted any category id — including one that belongs to a different
event. photo_categories carries both event_id (per-event) and is_global
(see backend/migrations/legacy/004_add_categories_and_cms.js); the v1
upload route should require either match.
Not a privilege issue (apiTokenAuth.js inherits the admin's powers, no
per-event scoping), but it lets a misconfigured uploader silently file
photos under a category the target event doesn't own — and the 201 echo
includes a category_id that makes no semantic sense.
Tighten to:
.where({ id: parsedCategoryId })
.andWhere(function () {
this.where({ event_id: event.id }).orWhere('is_global', true);
})
…and update the 400 message to "Unknown or out-of-scope category_id N".
OpenAPI description already documents the intended scope.
Tests deferred to a follow-up; v1 has no jest harness today, see PR
discussion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1 photo upload endpoint previously ignored any caller-supplied
category and inserted photos with category_id=NULL. That meant
programmatic uploads via API tokens (e.g. a photobox sidecar) landed
in picpeak as uncategorized, forcing operators to bulk-assign category
in the admin UI after each event.
Mirror the adminPhotos.js category-handling logic on v1:
- Read optional `category_id` from the multipart form body.
- Reject unknown ids with 400 (with the id in the error) so callers
fail fast on misconfigured envs instead of silently uncategorized
uploads.
- Set photos.category_id on insert.
- Flip photos.type to 'collage' when the category's slug is
collage/collages, matching adminPhotos.
Backwards-compatible: omitting category_id keeps the prior behavior
(insert with NULL category, type='individual'). OpenAPI spec + 201
response body updated to include the new field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fresh-install restart loop reported by @MrGabri (and confirmed by
@AloePacci with the user:0:0 workaround) had a clear root cause:
- Dockerfile pinned USER nodejs (UID 1001) before the entrypoint
ran, so the existing chown branch in init-production.sh:13 was
dead code.
- wait-for-db.sh (the actual entrypoint, not init-production.sh)
silently swallowed mkdir/EACCES on bind mounts with || true,
then a downstream migration error surfaced as the visible failure.
- Net effect on a typical Linux host where the bind-mount dir is
owned by UID 1000: container can't write, exits non-zero,
restarts forever with no clear error.
Switch to the standard Docker drop-privileges pattern:
1. Install su-exec, drop `USER nodejs` from the Dockerfile —
container now starts as root.
2. wait-for-db.sh: if running as root, chown /app/storage,
/app/data, /app/logs to nodejs and re-exec self via
su-exec nodejs:nodejs. App still ends up running as UID 1001.
3. Preflight check for non-root invocations (compose `user:`
overrides): verify the bind mounts are actually writable
before continuing. If not, exit 1 immediately with an
actionable error pointing at the docs — no more silent
restart loops.
Also:
- Delete backend/init-production.sh. It was an orphan — no caller
in the Dockerfile, compose, or anywhere else. Its chown logic
looked authoritative enough that @MrGabri ran it manually trying
to debug, which is what finally surfaced the EACCES.
- docker-compose.yml: drop user: + PUID/PGID env. The pattern-B
UID-matching workaround they implemented is obsolete now that
pattern A (root-then-drop) is in place.
- .env.example + README: drop PUID/PGID documentation.
- Add fresh-install smoke test workflow. Boots backend + postgres
against bind mounts owned by UID 1000 (the GitHub runner UID,
and the common-mismatch case on Linux hosts) and verifies:
+ container reaches healthy without restart-looping
+ chown happened (dirs now owned by 1001 inside the container)
+ node runs as nodejs, not root (su-exec drop worked)
+ /health returns status:ok
+ with --user 5005:5005 + unwritable mounts, preflight exits
loud with the expected error string
Verified locally end-to-end against a fresh Postgres + UID-501-owned
bind mount: backend reaches healthy in ~20s, chown applied, node
runs as nodejs, no restart loop. Docs in picpeak-docs cover the new
behavior + a Troubleshooting section for the install-path bugs
fixed in #484/#494/#511/#488.
Refs: #484
Audit follow-up to the Spanish-locale commit: `CustomerDetailPage` had
a hardcoded `<option>` list for the customer's preferred-language
selector — 5 entries (en/de/nl/pt/ru) that were missing both fr (an
existing gap) and es (the new one). Every other language selector in
the frontend (the navbar `LanguageSelector`, the `GeneralTab` default-
language dropdown, the `EmailConfigPage` per-language tabs) already
reads from the shared `SUPPORTED_LANGUAGES` constant, so adding es
there was enough for those. This one had drifted.
Now mapped from `SUPPORTED_LANGUAGES` so future locales only need to
touch one place.
Contributed by @AloePacci on issue #510. Drops their es.json into the
existing locale set, registers Spanish in the language selector with a
flag SVG matching the inline style of the other six locales, and
extends the email pipeline so es-language guests receive a localised
email subject/body where available.
Coverage:
- frontend/src/i18n/locales/es.json — 2132 translated keys. ~824
EN keys are not yet covered; i18next's `fallbackLng: 'en'` handles
those at runtime so the UI never renders a missing key. fr/nl/pt/ru
have a similar (smaller) gap and ship the same way.
- LanguageSelector.tsx — added the ESFlag inline SVG (red/yellow/red
horizontal bands, official #AA151B + #F1BF00; no coat of arms to
stay consistent with the other simple flag components) and a new
entry in SUPPORTED_LANGUAGES.
- emailProcessor.js — added .es to the domain-language heuristic, and
an `es:` row to the three inline-translated snippets
(passwordSecurityI18n / noPasswordI18n / passwordSetAtCreationI18n).
- 106_seed_es_email_template_translations.js (new) — idempotent
seeder for the four customer-facing templates AloePacci translated:
gallery_created, expiration_warning, gallery_expired, archive_complete.
Mirrors the pattern from 099. Template keys without an `es` row fall
back to `en` via the existing resolution chain in
emailProcessor.processTemplate — no functional gap, just untranslated
copy until someone fills them in.
What I deliberately did NOT take from the contribution: the proposed
in-place edit of migration 075 (history mutation — won't reseed for
existing installs anyway) and the whitespace/`gallery_list_html`-drop
churn in emailProcessor.js (would have regressed the #354 follow-up).
The semantic additions from those files are preserved via 106 and the
targeted edits above.
Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.
Re-applying exactly the same change set:
- `backend/migrations/core/072_add_max_upload_batch_size.js`
recreated, with a comment pointing at the regression in case the
same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
the setting from query cache and falls back to 95MB (Cloudflare-safe
headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
added the field to the state type + defaults + load path + the
Site-Configuration input.
Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
filename, so the runner skips re-applying. The setting row in
`app_settings` is also untouched (the deletion was source-only, no
down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
072 normally and seeds the setting at 95.
Photographers running the gallery as a client-selection tool want to
map a guest's picks back to source files for retouching. The
`general_use_original_filenames_for_downloads` toggle (#493) already
does this on the download side; this extends the same toggle to the
in-lightbox view so the camera filename is visible alongside the
photo while it's being looked at.
Tied to the same toggle on purpose — one switch controls both
surfaces. Off by default; existing galleries keep showing only the
position counter.
Wiring:
- gallery.js serializes `photos[].original_filename` and surfaces the
resolved toggle as `event.use_original_filenames` so the client can
decide whether to render it.
- The bespoke `PhotoLightbox` renders the original filename (falling
back to the storage filename only for pre-migration-062 uploads) in
a muted line under the position counter, truncated to keep the
toolbar tidy.
- `GalleryStoryLayout` mounts the same `PhotoLightbox`, so its
rendering follows along.
- `GalleryPremiumLayout` uses `yet-another-react-lightbox` instead;
added the Captions plugin and a `title` field on the slides so the
same name appears as a caption when the toggle is on.
The remaining layouts feed back into the main `PhotoLightbox` via
`PhotoGridWithLayouts`, so the prop reaches them through the layout
props bag.
Follow-up to #498. The toggle reached zip downloads but single-photo
downloads still landed on disk with the renamed `event_individual_NNN.jpg`
even when the admin had flipped the setting on. Two reasons, fixed
in lockstep:
- Frontend overrode the server's Content-Disposition with a hardcoded
`<a download="X">` attribute (`gallery.service.ts`, `photos.service.ts`)
where X was the sanitized `photo.filename` known to the client. So
the backend's correctly-formed `Content-Disposition` never reached
the disk write. Added `parseContentDispositionFilename` (RFC 5987 +
plain `filename=` fallback) and let the server name win when present.
- `secureImages.js` (enhanced/maximum protection's secure-download
route) was missed in #498 and still emitted a hardcoded
`filename="${photo.filename}"` regardless of the toggle. Wired it
through `getUseOriginalFilenames` + `buildContentDisposition` so it
matches the regular gallery download path.
Also exposed `Content-Disposition` via CORS so split (cross-origin)
frontend deployments can still read it from JavaScript. Same-origin
Docker deploys already had access; this is a defensive addition for
the split case.
Four gallery layouts were rendering the per-photo Like button without
gating on the master "Guest Feedback" toggle, so a guest still saw a
heart icon and could submit likes on events where the host had turned
feedback off. The other layouts (Grid / Justified / Masonry / Story)
already gated correctly with `feedbackEnabled && allowLikes` —
Rekoo-PS's note that "it's hidden in some themes" matches that split.
- CarouselGalleryLayout, MosaicGalleryLayout, TimelineGalleryLayout:
the existing conditional checked only `feedbackOptions?.allowLikes`,
missing the `feedbackEnabled` master gate. Added it inline.
- GalleryPremiumLayout: the per-card Like button rendered
unconditionally because PhotoCard never received the allow-likes
signal. Added an `allowLikes` prop on PhotoCardProps, plumbed
`feedbackOptions?.allowLikes` down from the parent, and wrapped the
button in `feedbackEnabled && allowLikes`.
The follow-up "default guest-feedback ON" request from Rekoo-PS in
the comments is a separate feature (admin > General > Event Creation
default) and out of scope for this fix.
Two adjacent swipe-time defects, one diagnosis each:
1. Height differed between current and neighbouring slides during a
swipe but matched when the arrow buttons advanced the carousel.
Cause: neighbour slides wrap their image in a div with extra `px-2`
horizontal padding while the current slide does not. `object-contain`
then sees a narrower container on neighbours, so wide images cap on
width first and render shorter than the same image at the current
position. Removed the padding so both slots share the same container
geometry. Arrow-button navigation looked fine because it never
showed the neighbour layout side-by-side.
2. The image flashed black for ~100–400 ms each time a swipe committed
to the next slide. Cause: the 3-slide track has no React keys, so
React reconciled slides by position. After commit the photo at every
position changed (`prev → current → next` shifts left), every slot's
`<AuthenticatedImage>` saw a new `src` prop, and its fetch effect
restarted from the placeholder state — including the slot that was
the user's "next" slide a moment ago and held a fully-loaded image.
Added a stable `key` derived from `photo.id` so React MOVES existing
DOM nodes across slots instead of refetching. 2-photo galleries are
a key-collision edge case (`prev === next`), so they fall back to
slot-prefixed keys to keep siblings unique; behaviour there is no
worse than today.
The dashed-border upload area in `PhotoUpload` (admin) and
`UserPhotoUpload` (gallery user-upload) is styled and labelled as a
drop zone — every locale's `upload.clickToUpload` already reads
"Click to upload or drag and drop" or its translation — but neither
component had any `onDragOver` / `onDragEnter` / `onDragLeave` /
`onDrop` handlers. Files dropped on the zone fell through to the
browser's default behaviour (open the image in a new tab), which is
what Rekoo-PS reported.
Added native HTML5 drag-and-drop wiring on both components, plumbed
through the same filter/limit/toast pipeline used by the click path
(`addFiles` helper). Visual highlight on drag-over via an `isDragOver`
flag; the listener guards against the `dragleave` strobing that fires
on every child node. Also reset the `<input>` value after onChange so
re-picking the same file still triggers an upload — matches the
new drop-then-pick mental model.
Two latent install-time issues that emitted scary postgres ERROR lines
on every fresh start but didn't actually break anything. MrGabri flagged
them after #494 had already cleared the FK-ordering crash.
1. Migration 035 builds three `CREATE INDEX` statements against
`backup_runs(created_at, …)`, but 029 creates the table with
`started_at` and no `created_at`. The wrapping try/catch silently
swallowed the resulting `column "created_at" does not exist` ERROR,
so the migration "succeeded" without ever creating the indexes.
Switched 035 to reference `started_at` (same chronological semantics)
and added migration 105 to create the same indexes idempotently for
deployments whose 035 already ran and silently failed.
2. `run-migrations-safe.js` snapshots `appliedFilenames` *before*
`detectExistingSchema()` runs. When `detectExistingSchema()` inserts a
row for e.g. `004_add_categories_and_cms.js` (because its tables exist
from a partially-completed prior install), the subsequent migration
loop still doesn't know about that insert, attempts the legacy
migration anyway, and its transaction-internal
`insert into migrations` conflicts with the row already there.
Re-query the applied set after detectExistingSchema so the loop sees
the corrected snapshot.
No behavioural change for healthy installs. New installs no longer log
the `column "created_at" does not exist` or `duplicate key value
violates unique constraint "migrations_filename_unique"` ERRORs.
general_default_language is stored as a JSON string (e.g. "\"pt\"").
getRecipientLanguage() returned the raw value including quotes, causing
the translation lookup to miss every match and fall back to English.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Accented chars (ã, ç, é, etc.) were silently dropped by the slug
regex because \w only matches ASCII. NFD decomposition + combining
mark removal converts them to ASCII equivalents instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When navigating to the Settings page, useSettingsState called
i18n.changeLanguage() with the server-stored general_default_language
value on every settings query resolution. This caused the admin UI
language to reset to the server default (e.g. "en") regardless of the
language the user had selected via the LanguageSelector.
The general_default_language setting is intended as the default for
public galleries, not for controlling the admin UI language. The admin
UI language is already persisted via localStorage through
i18next-browser-languagedetector and should not be overridden by server
settings.
Remove the i18n.changeLanguage() call from the useEffect that
initialises settings state from the API response.
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.
- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
invalidated when the toggle flips so the next download rebuilds with the
new names.
- Falls back to the storage filename whenever `original_filename` is null
(legacy uploads predating migration 062).
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.
Backend:
- imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
- migration 104: photos.preview_path + lightbox_preview_enabled setting
(off by default, JSON-stringified for SQLite/Postgres parity)
- GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
ETag based on mtime+photoId+watermarkHash
- preview_url surfaced in the photo response only when the toggle is on
- admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
skipping videos
- backup walk + archive cleanup + photo-delete now include previews/
Frontend:
- PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
- ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
Regenerate All Previews button (gated until the toggle is on)
- en/de locale strings; nl/pt/ru/fr fall back to en
Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
Real root cause behind MrGabri's fresh-Postgres install crash, surfaced
by his second log dump after #488 silenced the FATAL noise:
Initial setup failed: error: alter table "events" add constraint
"events_hero_photo_id_foreign" foreign key ("hero_photo_id")
references "photos" ("id") on delete SET NULL
- relation "photos" does not exist
initializeDatabase() in src/database/db.js declared the FK inline at
events createTable (line 89), but the photos table is created later
in the same function (line 203). On Postgres this is a hard error —
the referenced table must exist at FK-declaration time. SQLite
silently tolerated it because its FK enforcement is lazy and the
inline declaration just became a column with no FK metadata.
Why no existing Postgres install hit it: initializeDatabase only
runs the createTable block on `if (!hasEventsTable)`. Once a
deployment has the events table from any prior run, the path is
skipped. So the bug only ever fires on a truly fresh Postgres
install — which is exactly MrGabri's scenario, and which our smoke
suite never exercises (it runs against a long-lived dev stack).
Fix:
- events createTable: drop the inline FK; column declared as a plain
integer with an explainer comment.
- After both tables exist (post photos createTable): db.schema
.alterTable('events').foreign('hero_photo_id').references...
Wrapped in a try/catch that swallows "already exists" so re-runs
on installs that previously got into a half-state don't fail boot.
Verified by docker compose down -v + up against the dev stack — no
FK error, all migrations apply, FK present in pg_constraint with
the expected definition.
Admin > Users page crashed with "TypeError: e.split is not a function"
on native installs (SQLite default). Reported by @blazmaric in #485
with a clean diagnosis: SQLite returns lastLogin / createdAt /
updatedAt as integer milliseconds since epoch, while Postgres
returns ISO strings via the standard JSON serialiser. The page used
parseISO() on the raw value and parseISO trips on numbers.
Fix at both layers — defence in depth:
- backend/src/routes/adminUsers.js: new toIso() helper applied in
transformUser + transformInvitation. Coerces Date / number /
numeric-string / null to a single ISO 8601 string contract before
the response leaves the API. Protects every consumer (frontend
AND external API tokens / n8n) regardless of which DB driver is
underneath.
- frontend/src/services/userManagement.service.ts: same helper as
defence-in-depth for stale backends mid-deploy and any cached
pre-fix response shape. Also surfaced an existing
transformInvitation gap — invitations endpoints were returning
raw response.data.invitations without going through the
transformer.
10 unit tests pin the toIso contract: all known driver shapes
(Date, number, numeric-string, ISO-string, null/undefined/empty)
plus the full transformer paths for transformUser and
transformInvitation.
Out of scope: same epoch-ms surface may exist on other admin pages
that were never tested against SQLite (events list, customers,
webhooks, api tokens, activity log). Worth a follow-up audit pass
to apply toIso() in every snake_case→camelCase transformer the
admin routes use, but the immediate Users-page crash is the only
reported one and shipping that fix unblocks @blazmaric.
Two follow-up fixes inside the same install-experience surface as
the previous commit:
1. **Removed `docker compose exec -T backend npm run migrate`** in
both install_docker and update_docker_installation. The backend
container's wait-for-db.sh already runs `npm run migrate:safe`
on startup; the script was racing it with a separate (and
non-safe) `npm run migrate`. That race is the most likely
actual mechanism behind #484's "relation 'photos' does not
exist" error on the second install attempt — partial schema
visible to one of the two parallel migrators. Replaced with a
bounded wait for the backend container to become healthy
(Docker healthcheck reports green only after wait-for-db.sh
finishes its migration pass).
2. **Added the missing frontend container** to the script-generated
compose. The script previously generated a postgres + redis +
backend stack with no frontend at all (backend on host port
3001), while the documented production install
(docker-compose.production.yml) ships postgres + redis +
backend + frontend (nginx /api proxy on host port 3000). That
shape divergence is half of issue B in #484 — script-installed
admins had no frontend container and were left wondering where
the UI lived. Aligning both compose files on the same shape
eliminates the divergence; the frontend uses curl in its
healthcheck (frontend/Dockerfile explicitly `apk add curl`)
unlike the backend.
The remaining piece of issue B — picking ONE canonical install
path (build-from-source script vs. prebuilt-image production
compose) and deprecating the other — is a deployment-strategy
call that deserves its own design pass. Both paths now produce
architecturally-equivalent stacks.
Three install-experience bugs that compounded into MrGabri's "fresh
install fails" report:
1. **postgres healthcheck noise.** `pg_isready -U <user>` without
-d defaults to probing a database whose name matches the user.
Since DB_NAME defaults to picpeak_prod (not picpeak), every
healthcheck interval logged
FATAL: database "picpeak" does not exist
into postgres logs even though the install was working
correctly. Reporter saw the FATAL, assumed broken, restarted
with DB_NAME=picpeak, hit a tainted-state migration error on
the second try, filed a bug. Fixed in both
docker-compose.production.yml and the inline compose generated
by scripts/picpeak-setup.sh — pin -d to ${DB_NAME} so the
probe hits the real database.
2. **backend container shows perpetually `unhealthy`.** Both
compose files used `curl -f` for the backend healthcheck, but
backend/Dockerfile only installs dumb-init + postgresql-client +
ffmpeg — no curl. Switch to wget --no-verbose --tries=1 --spider
to match what backend/Dockerfile's own HEALTHCHECK already
does. Now docker ps, docker compose ps, and the backend image's
built-in healthcheck all agree.
3. **stale separate `workers` container.** scripts/picpeak-setup.sh
still generated a second container running `npm run workers`
alongside the backend, but workers (fileWatcher,
expirationChecker, emailQueueProcessor, backgroundProcessor,
webhookWorker) have been started by server.js in-process for
a while — see the comment at line ~895 of the same script for
the systemd-side cleanup. The duplicate container caused two
file watchers and two expiration checkers to compete for the
same DB rows. Removed from the generated compose; install +
upgrade paths now stop and rm any pre-existing picpeak-workers
container.
Doesn't address issue B's bigger architecture mismatch (the script
generates a build-from-source compose with no frontend container,
while docker-compose.production.yml uses prebuilt images with a
separate frontend container). That deserves its own design pass
to pick a canonical install path and align — out of scope here.
The gallery promotional banner (#440) read as visually offset from
the gallery footer because:
- Footer used `container text-center px-4` (full container width,
centered text).
- Promo block used `container py-4 sm:py-6` with an inner
`max-w-3xl mx-auto` wrapper holding left-aligned text — a
narrower column with left-aligned content sitting in the
middle of the page.
Two issues compounded: the column was narrower than the footer AND
its text alignment differed. Reported by Rekoo-PS in #482 with a
screenshot showing the misalignment, with a request for an admin
alignment option.
Fix:
- Drop the inner max-w-3xl wrapper. Promo content now spans the
same .container width as the footer, eliminating the
narrower-column visual.
- Default text alignment changed from left → center to match the
footer.
- New `branding_promo_alignment` setting ('left' | 'center' | 'right',
default 'center'). Surfaced as a dropdown next to the existing
Position dropdown on the BrandingPage. Live preview block on the
BrandingPage mirrors the gallery render so admins see what
guests will see.
- Also replaced the no-op `prose-sm` prose-modifier with a real
`prose prose-sm` outer class so the existing `prose-a:text-accent`
modifier actually takes effect (it didn't before — modifiers
without an outer .prose are silently ignored by Tailwind
Typography).
Migration 103 seeds the new setting at 'center' so existing
installs that have a promo banner today see the corrected
alignment immediately on next deploy.
i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and
flagged for native review per project convention.
PR #477 moved Trivy from the merge-* job into the per-arch build-*
matrix scanning by digest. The amd64 leg works; the arm64 leg
crashes with:
remote error: no child with platform linux/amd64 in index
ghcr.io/.../<image>@sha256:<digest>
Root cause: docker/build-push-action wraps every push in an OCI
index — the actual image manifest sits next to a SLSA provenance
attestation manifest as siblings under the digest. Trivy's remote
backend defaults to linux/amd64 when resolving an index, so:
- amd64 leg → looks for amd64 child → finds the amd64 image → ok.
- arm64 leg → looks for amd64 child → finds NO amd64 child
(the only platform child is arm64) → fails.
Fix: set TRIVY_PLATFORM = ${{ matrix.platform }} on each leg's
Trivy step. Each scanner then asks for its own arch and finds it.
SLSA provenance attestation stays attached to the per-arch images
— a real win for supply-chain visibility we'd lose if we'd
disabled provenance instead.
amd64 was the only thing keeping CI partly green; this restores
full green across both legs without touching the build artifact
shape.
Initial pinning shipped a tag that doesn't exist in the
aquasecurity/trivy-action repo. Workflow run failed with:
Unable to resolve action 'aquasecurity/trivy-action@0.28.0',
unable to find version '0.28.0'
The repo's tags use a v prefix (v0.36.0, v0.35.0, …). Bumping
both occurrences (build-backend and build-frontend matrix jobs)
to v0.36.0, which is the latest stable as of 2026-04-22.
Resolves the intermittent "no child with platform linux/amd64 in
index" failure on the merge-backend job — and fixes the same latent
bug on merge-frontend before it surfaces.
Two compounding root causes per Luca's diagnosis:
1. aquasecurity/trivy-action@master was unpinned, so the action and
its bundled Trivy binary float on every CI run. A green build
could flip red overnight without a single repo change.
2. Trivy was asked to scan a multi-platform OCI index by tag (the
merge-* jobs ran AFTER manifest creation). Its remote resolver
cannot reliably pick the right per-arch child out of an index
reference — it needs a single-platform reference (digest, or a
--platform flag).
Fix:
- Move the Trivy + upload-sarif steps OUT of merge-backend /
merge-frontend and INTO the per-arch build-backend / build-frontend
matrix jobs. Each leg scans the image it just pushed by its
sha256 digest (`...@${{ steps.build.outputs.digest }}`), which is
always single-platform by construction.
- Pin aquasecurity/trivy-action@0.28.0 (was @master).
- Distinct SARIF category per arch
(`backend-vulnerabilities-linux-amd64`, …-arm64) so an
amd64-only finding in a base layer doesn't get masked by the
arm64 scan in the Security tab.
- Move security-events: write down to the build-* jobs (where the
scan now runs) and remove it from the merge-* jobs (which only
publish the manifest now).
Out of scope: flipping `exit-code: '1'` to actually gate CI on
findings. Worth doing as a separate follow-up after an audit pass —
landing it here would surprise beta with a red build for any
pre-existing CRITICAL/HIGH in current images. Inline TODO in the
workflow notes the deferral.
Background: galleryOgService already serves OG/Twitter Card meta tags
to social-crawler User-Agents (WhatsApp, Facebook, Slack, Telegram,
Discord, ~21 in total) for /gallery/:slug URLs. Today the og:image
is always the brand logo with the inline rationale "no protected
photo content".
#474 asked for a hero/cover photo preview. The trade-off is that any
URL embedded in og:image is fetched unauthenticated by every
link-preview crawler — so an opted-in image is effectively public
to anyone the gallery URL is shared to. Ship as a per-event boolean,
default FALSE, so existing galleries never start surfacing photos
without explicit admin intent.
Schema (migration 102):
- events.og_image_share_enabled BOOLEAN NOT NULL DEFAULT FALSE.
Backend:
- galleryOgService.buildOgMetadata: when opt-in is on AND a
hero_photo_id is set AND the photo has a generated thumbnail,
emit og:image as /og/gallery/:slug/cover. Falls back to the
brand logo on any miss (deleted hero, missing thumbnail, no
opt-in) so a half-configured gallery still gets a polished
preview rather than a broken-image src.
- galleryOgService.handleGalleryOgCover: new public endpoint that
streams the hero thumbnail. Validates slug shape, checks the
opt-in flag + hero presence + thumbnail existence; returns 404
on any failure. ETag = thumbnail mtime + photo id so a
regenerated thumb busts crawler caches. Cache-Control:
public, max-age=300 (short — admins shouldn't wait an hour for
a cover swap to land in chat previews).
- server.js: mount the new GET /og/gallery/:slug/cover route. The
existing nginx ^~ /og/gallery/ proxy block already covers it.
- adminEvents.js: validator + persistence on POST + PUT.
formatBoolean coercion so SQLite (0/1) and Postgres (boolean)
both behave correctly.
Frontend:
- Event type + UpdateEventData carry og_image_share_enabled.
- EventDetailsPage adds a checkbox under the HeroPhotoSelector,
disabled when no hero photo is picked. Help text deliberately
spells out the public-by-design consequence — admins shouldn't
flip this on for a sensitive gallery without realising what
they're sharing with link-preview crawlers.
Tests: 8 new in galleryOgService.shareImage.test.js — pin the
cover-vs-logo decision contract (3 cases) plus the defensive
fallbacks (deleted hero, missing thumbnail) and the 404 contract
on the cover endpoint (4 cases). The 404 tests assert that
ensureThumbnail() is NOT called when opt-in is off, so a future
refactor can't accidentally widen the unauthenticated cover
endpoint to expose a hero the admin hasn't shared.
i18n: en + de hand-translated; nl + pt + ru + fr machine-translated
and flagged for native review per project convention.
The trigger: PR #458 mounted requireCustomerPortalEnabled which
410'd every /api/customer/* + /api/admin/customers/* request when
the master toggle was off. Some browsers cached that 410 (no
Cache-Control header was set, so heuristic freshness applied —
the wrong default for an authenticated/sensitive surface).
PR #470 reverted the middleware, but a customer whose tab cached
the 410 still saw 410s until they hard-refreshed.
Add noStoreCache middleware and mount it in front of both route
groups. Every response (200, 4xx, 5xx) now carries
`Cache-Control: no-store, no-cache, must-revalidate, private`
plus the HTTP/1.0 Pragma + Expires fallbacks. Any future
transient error from these endpoints can no longer get pinned in
browser or proxy caches and outlive its cause.
Cost is one setHeader per request; applied per route group rather
than globally so static assets + galleries keep their own caching
strategy unchanged.
Includes a dedicated unit test pinning the header set so a future
cleanup pass can't quietly drop it and re-introduce the bug.
4 unit tests pinning the contract of the customer-minted JWT
re-check added in #470:
- via='customer' + customerId, assignment present → next() runs.
- via='customer' + customerId, assignment removed → 403 with
CUSTOMER_ASSIGNMENT_REVOKED code.
- customerId in payload but `via` claim missing → no re-check
(defends against a future refactor accidentally widening the
gate to match every legacy session that happens to carry a
customerId field).
- per-event-password JWT (no via, no customerId) → no
event_customer_assignments query at all (asserted by counting
db() invocations — a regression that quietly added a re-check
here would 403 every guest the moment any unrelated customer
was unassigned from any event).
Same mock pattern as customerAuth.middleware.test.js. The re-check
is the load-bearing piece behind the "Manage galleries" dialog
UX promise — these tests guard it explicitly.
5 new tests covering the diff math (added/removed), the
archived-event filter, the no-op short-circuit when wanted equals
existing, and the type-coercion of the wanted-list input. Mirrors
the existing setAssignmentsForEvent suite shape so the inverse-
direction service function carries equivalent regression coverage.
This function is the writer behind the "Manage galleries" dialog
and the verifyGalleryAccess re-check together form the access-
control story for the whole feature — getting the diff math
wrong here means assignments don't actually revoke, which is the
entire promise of the new UI.
The Dashboard "Recent Activity" widget and the header notifications
dropdown both rendered raw activity-type strings (e.g. the literal
"feature_flags_updated") for any type missing from their lookup
maps — including everything emitted by the recently-added customer
portal (#354), webhooks (#327), API tokens (#322), event types,
event-publish flow, admin user management (#350), and the
feature-flags reorg itself.
Two coordinated changes:
1. Smart formatter for feature_flags_updated. The backend writes
`metadata.changed = { [flagKey]: { from, to } }` on every save.
New formatFeatureFlagsChanged() helper in admin.service.ts reads
that diff and renders:
- 1 change → "Customer Portal enabled"
- N changes → "3 features updated: Customer Portal enabled,
Calendar disabled, Quotes enabled"
Per-flag display labels source from `settings.features.<key>.title`
so they stay in sync with the Features tab. Unknown flag keys
fall through to a humanised version of the key.
2. 33 missing activity types added to BOTH renderers and to the
`admin.activities.*` + `admin.notificationMessages.*` i18n
namespaces across all six locales. Coverage groups: customer
portal (13 types), admin user management (6), webhooks (3),
API tokens (2), event types (4), event publish/logo (3), bulk
delete (1), and assorted post-merge surfaces (4).
The notifications.service.ts switch + admin.service.ts fallback
message map are still duplicated; consolidating them into a
single source of truth is a follow-up worth doing before the
next significant addition. For now both stay in sync via this PR.
en + de hand-translated. nl + pt + ru + fr machine-translated and
flagged for native review per project convention.
Settings → Features showed the customer-portal toggle as "Accounts"
("Konten" in DE, "Comptes" in FR, etc.) — the deeper sub-nav label
inside ClientsLayout — while the prominent menu-bar entry the admin
actually clicks first reads "Clients" / "Kunden". The mismatch was
confusing on first encounter ("which one do I look for?").
Align the Features tab card title and the "Sidebar:" callout with
the menu-bar wording (`navigation.clients`) across all six locales.
The sub-nav inside ClientsLayout keeps its own "Accounts" label —
that one matches the /admin/clients/accounts URL and is correct.
formatBrandingSettings was updated when the BrandingSettings
interface added the footer-overhaul fields (#441 / #440), so the
admin BrandingPage initialised them as empty strings on every load.
Saving any other field then sent the form's empty socials /
promo_markdown / promo_position back to the backend and wiped the
saved values from the DB. The public gallery footer kept rendering
the old values until the next save, which is why the bug appeared
asymmetric (visible to galleries, gone from the admin form).
Add the missing read mappings for the seven branding_* keys so the
form round-trips them correctly.
Reported by @Rekoo-PS in #460 (split out of #447).
The pagination-clamp useEffect added in #448 (commit 9c4a96f) was
inserted at the top of the component body, BEFORE the useQuery that
declares `data`. Because the useEffect's dependency array
`[data?.pagination, page]` is evaluated immediately when that line
executes, every render hit a temporal dead zone access on `data` and
threw `ReferenceError: Cannot access 'data' before initialization`
— minified to "Cannot access 'I' before initialization" in the
production bundle, crashing the entire page.
TypeScript caught this at the time
(`Block-scoped variable 'data' used before its declaration`) but the
project's build doesn't fail on TS errors so it shipped anyway.
Move the effect to immediately after the useQuery so `data` is in
scope. Behavior unchanged otherwise — same dep array, same setPage
clamp logic.
Reported by @derooijmnl on v3.45.1-beta.0.
Post-merge cleanups after #403 (customer portal):
- Renumber 090_backfill_photo_dimensions_v2.js → 096 to follow #403's
090_add_customer_accounts ... 095_add_customer_portal_flag chain.
- customerAccountsService.js: TODO note on must_change_password
documenting that the column is decorative until an admin
pre-loaded-password flow ships (mirrors what adminAuth does for
must_change_password today).
- customerAuth.js: doc-comment on the /login route explaining why the
customerPortal feature flag deliberately doesn't gate it (toggle off
hides UI, doesn't revoke existing-customer access; deactivate
individual accounts to lock out).
- 095_add_customer_portal_flag.js: header comment said "Migration 094"
(copy-paste from 094) — now matches the filename.
The aspect-aware gallery layouts (masonry / mosaic / justified) read
photo.width and photo.height to size each card to the source's real
proportions. Two import paths were inserting rows without those
fields, which forced MasonryGalleryLayout to fall back to a hard-coded
800×600 default — every card came out the same shape, so users
reported masonry as "always cropped to 1:1ish" no matter which
thumbnail fit mode they chose.
- fileWatcher.js: extract dims with sharp.metadata() before insert.
- s3AutoImporter.js: same, materialising a tmp local copy via
withLocalCopy so it works in S3 mode.
- migration 090: backfill any pre-existing rows with NULL dims
(skips videos, skips S3 deployments — those need the writer fix
alone since migrations cannot reach the storage backend).
- imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to
'inside' (only kicks in when the seed setting is missing — existing
installs keep their saved value). Add UI tooltip recommending
'inside' for masonry/mosaic/justified, 'cover' for uniform grids.
i18n covers all six locales.
Previously these locales fell through to en for every customer.* /
customers.* / settings.customerSurface / settings.features.customerPortal
key. Machine-translated and flagged in the PR description as needing
native review.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keeps the customer-surface branding toggles adjacent to the other
brand-visibility controls instead of floating at the bottom of the
page, where they were easy to miss.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds back the "Show logo" / "Show company name" toggles for the
customer dashboard, scoped to /customer/* surfaces only. Lives as a
dedicated card at the bottom of Settings → Branding, gated by the
customerPortal feature flag so admins who haven't enabled the portal
don't see it.
* Backend: restored GET/PUT /admin/settings/customer-surface
endpoints, whitelisted only to the two branding keys
(customer_show_logo, customer_show_company_name). The
calendar/quotes/bills feature globals that used to live on this
endpoint are now driven by the Features tab (feature_flags table).
* customerAccountsService.getCustomerSurfaceGlobals() reads from
app_settings again so /api/customer/auth/session honours the
toggles in its branding payload.
* New CustomerDashboardBrandingCard component with its own save
flow — separate from the main BrandingPage payload so flipping a
toggle doesn't replay the full branding mutation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
RequireFeature calls useFeatureFlags(), which throws unless mounted
inside FeatureFlagsProvider — and that provider only wraps
AdminLayout. So unauthenticated visitors hitting /customer/login
crashed into the React error boundary with 'Oops! Something went
wrong'.
The customerPortal flag continues to hide every admin-side surface
(sidebar entry, /admin/customers routes, CustomerAccountPicker on
event forms), which is what the flag is actually for. The
customer-side tree stays reachable so existing customers can still
log in even if the admin flips the flag off temporarily.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The route was registered in upstream/beta's server.js but dropped
during the rebase squash — the Features tab GET/PUT both 404'd, so
the customerPortal flag (and every other flag) couldn't be toggled.
Restored the mount in its upstream/beta position.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
server.js was still requiring ./src/middleware/requireCustomerPortal
— a file deleted during the AdvancedFeaturesTab cleanup — which
crashed the backend on boot in production (MODULE_NOT_FOUND).
The customerPortal feature flag is now enforced on the frontend via
<RequireFeature flag="customerPortal" /> route guards (App.tsx) and
AdminSidebar visibility. Defence in depth is provided by
customerAccountsService.isCustomerPortalEnabled() in adminEvents.
Routes themselves are still protected by adminAuth / customerAuth.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The customer-portal squash inadvertently reverted the upstream/beta
fix from PR #427: production NODE_ENV was flipping the cookie Secure
flag back to hard `true`, which broke admin login on
HTTPS-frontend → HTTP-backend reverse-proxy stacks (browser drops
the Secure cookie over HTTP, login loops indefinitely).
Restored upstream/beta's tokenUtils.js verbatim and re-layered only
the customer cookie helpers (CUSTOMER_COOKIE_NAME,
setCustomerAuthCookie, clearCustomerAuthCookie,
getCustomerTokenFromRequest) on top.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.
* New `customerPortal` feature flag (foundation flag for the
not-yet-built calendar/quotes/bills/messaging customer
surfaces). Defaults FALSE on fresh installs, TRUE on existing
installs (events > 0) via migration 095 so live customer
accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
event_customer_assignments, customer_password_resets, plus
RBAC permissions customers.view / .create / .delete granted
to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
deactivate, reset password) + /api/customer/auth/* +
/api/customer/* (login, dashboard, accept-invite, reset).
Customer JWT bypass minted via
/api/customer/events/:slug/access-token so existing gallery
middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
customerPortal, with login / dashboard / accept-invite /
reset pages and a customer-side sidebar layout.
/admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
Customer portal card. The maintainer's Features tab stays the
single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
when the flag is off; backend ignores customer_account_ids in
that case instead of erroring the whole event save.
Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The "apply recommended preset on event-type change" effect was firing on
the initial mount AND every time the eventTypes API resolved (because
availableEventTypes is recomputed when that query settles). The first
fire matched the wedding default and clobbered the global Branding
theme that the previous effect had just applied.
Track the previous event_type in a ref and bail out when it hasn't
actually changed. The Branding-default effect now wins on first paint,
and the recommended-preset behaviour still kicks in when the user
manually picks a different event type.
Restores the green state of smoke spec 07 (#323-B regression).
Combined footer overhaul:
- Per-CMS-page show_in_footer toggle (#441) — admins can hide
Impressum / Datenschutz from the gallery footer when an external
privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
global default authored as markdown in branding settings, plus a
three-way per-event override on the Edit Event form
(inherit / custom / off). Backend nulls promo_markdown automatically
when mode != 'custom' so stale text never persists.
Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.
i18n covers all six locales (en/de/nl/pt/ru/fr).
Targets the beta branch.
Bulk-deleting all events on the current page left the list empty until
manual reload. After the React Query refetch returned `events: []` with
a smaller `totalPages`, the page state was stuck on the old (now
out-of-range) page index — the backend correctly serves an empty page
for `page > totalPages`, but the UI had no logic to step back.
Add a useEffect that watches `data.pagination.totalPages` against the
current `page` and resets `page = max(1, totalPages)` whenever the
result count shrinks. Fires after every refetch so it covers bulk
delete, individual delete, archive, and any filter change that
shrinks the result set — same one-line guarantee.
Reported by @Rekoo-PS in #442.
iSchumi6210 reported that with the global "Require expiration date"
toggle ON, an admin couldn't clear the expiration on an existing event
via the Edit Event form. The PUT returned 400 "Expiration date is
required."
The cause was intentional in the original code: the global setting was
enforced on both create AND edit, so once flipped ON, no event could
ever be cleared of its expiration — not even by admins editing one-by-
one. Reproduced the exact scenario byte-for-byte against beta:
Toggle ON → POST /admin/events {expiration_days: 30} → 200 created
Toggle ON → PUT /admin/events/:id {expires_at: null} → 400 rejected
The setting now controls only the create-time default. On edit, an
admin can clear the field and the value persists as NULL ("never
expires"). Matches CMS-style admin tool conventions where field-
required-by-default doesn't lock the field after creation.
Backend: drop the `getEventFieldRequirements()` enforcement on the
expires_at branch in PUT /admin/events/:id. Empty/null on edit
normalizes to NULL.
Frontend: drop the matching `requireExpiration && !editForm.expires_at`
toast in EventDetailsPage. The variable is no longer referenced, so
remove its declaration too.
Verified end-to-end with toggle ON:
STEP 1: create with expiration → ok (unchanged)
STEP 2: create without expiration → backend auto-applies default 30d
(create-time enforcement intact)
STEP 3: PUT {expires_at: null} on existing → "Event updated
successfully" (was 400)
STEP 4: DB column expires_at is NULL
STEP 5: PUT {expires_at: ''} also accepted (matches what an HTML
date input sends when cleared)
Smoke 13/13 green; no regressions.
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.
Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.
Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
Migration 088 detects existing-vs-fresh installs from the events
table:
* Existing install (events>0) → all 9 flags TRUE so nothing
vanishes from an admin's UI on upgrade.
* Fresh install (events=0) → spec defaults: galleries,
reminderEmails, analytics, userManagement TRUE; calendar,
calendarBooking, quotes, bills, messaging FALSE.
- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
`settings.edit`. Server enforces the same dependency rules the
frontend does (galleries always TRUE, quotes=false → bills=false,
calendar=false → calendarBooking=false). PUT writes one
`feature_flags_updated` activity log row with the diff.
Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
AdminLayout so flag fetches carry the auth cookie. Source of truth
is the server response; staged is a local copy that the Features tab
edits and the Save button PUTs.
- `RequireFeature` route guard for /admin/analytics and /admin/users —
redirects to /admin/dashboard when the corresponding flag is OFF.
- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
Branding, Event Types, Backup, CMS Pages (now Settings tabs).
Feature-gated: Analytics, Users.
- Old top-level routes (/admin/email, /admin/branding, /admin/event-
types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
/admin/settings?tab=<key> so existing bookmarks don't 404.
- SettingsPage rewritten with a 6-group inner-nav (General /
Content & Appearance / Communication / Privacy & Security /
Integrations / System) and 19 tabs. New Features tab is the
default landing tab. URL ?tab=<key> roundtrips with state — deep
links and the back button work.
- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
Analytics + User Management (the two flags that gate sidebar items
in this PR). All other toggles disabled with a "Not yet available"
lockedReason — the cards still render so admins see the roadmap, but
the flag has no UI effect until the surface ships in its own PR. The
galleries card is locked TRUE per spec (foundation, can't be off).
- Live SidebarPreview reflects unsaved staged changes — admins see
what their sidebar will look like before they save.
- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
Features tab copy, the new Settings group labels, and the lifted
tab titles.
Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
rule enforced (bills forced false when quotes=false even when bills=
true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
enabled, toggling Analytics off + saving updates the sidebar +
redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.
The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.
Changes:
- Add getRange(relPath, start, end) to the StorageBackend interface +
LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
(downloadStream with Range header). Needed for video range requests
on S3 — previously the photo route did fs.createReadStream(filePath,
{start, end}) which is local-only.
- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
via storage.get. Watermark application path materializes the source
via withLocalCopy (no-op in local mode, downloads to a tmp file then
cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
works.
- /:slug/photo/:photoId — branches on source_origin: external/reference
photos still use the local fs path (NAS mounts are local), managed
photos use the storage abstraction. Video range requests pass through
to storage.getRange. Pre-generated watermarks served via storage too.
On-the-fly watermark generation uses withLocalCopy for managed photos.
- /:slug/hero/:photoId — hero images are always managed-storage keys
(imageProcessor.generateHeroImage writes via the storage abstraction),
so this just switches to storage.stat + storage.get. Watermark via
withLocalCopy.
Verified end-to-end against minio in dev:
POST /api/admin/photos/N/upload → photo + thumbnail land in S3
GET /api/gallery/<slug>/thumbnail/<id> → 200, JPEG 300x300 ✓
GET /api/gallery/<slug>/photo/<id> → 200, JPEG 1200x800 ✓
GET /api/gallery/<slug>/hero/<id> → 200, JPEG 1920x1080 ✓
ETag round-trip (If-None-Match) → 304 ✓
Backend logs → no errors
LocalFs regression: 13/13 smoke tests pass.
Closes#432.
Two intertwined bugs reported in #427 by @iSchumi6210:
1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true
when NODE_ENV=production. Over plain HTTP the browser drops the Secure
cookie → next /auth/session request returns 401 → redirect back to
/admin/login → no error shown. picpeak-setup.sh writes
NODE_ENV=production but never writes COOKIE_SECURE, so every first-time
install without a reverse proxy hits this.
2. Admin password is generated but admins can't find it. The 001_init.js
migration writes the generated password to data/ADMIN_CREDENTIALS.txt
inside the backend container, but picpeak-setup.sh only copies it out
when --reset-admin-password is passed. Default-path users never see it
and resort to manual bcrypt updates in psql.
Changes:
- tokenUtils.js: production default goes from `true` to `'auto'`. On real
HTTPS req.secure is true → Secure flag is still emitted (no security
regression for reverse-proxy deployments). On plain HTTP req.secure is
false → Secure flag omitted → login works. Users who explicitly want
the strict HTTPS-only behaviour can still set COOKIE_SECURE=true.
- .env.example: rewrite the COOKIE_SECURE block to make the new default
obvious and explain when to override (set =true for strict, =false to
skip the per-request check, leave unset for the auto behaviour).
- picpeak-setup.sh (both Docker and native paths):
- Write COOKIE_SECURE=auto explicitly to the generated .env (defense in
depth so the right behaviour is preserved even if the backend default
flips again later)
- After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the
backend container/data dir to the host data dir, chmod 600, and print
the email + password to the install output. The credentials file
remains as a backup record that the operator should delete after
noting the password.
Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE:
production, unset → HTTPS: secure=true ✓ HTTP: secure=false ✓ (was both true)
production, =true → both: secure=true (strict opt-in preserved)
production, =auto → HTTPS: secure=true HTTP: secure=false (already-correct)
development, unset → both: secure=false (dev unchanged)
External (source_origin='external') photos always had thumbnail_path=NULL,
so the gallery returned thumbnail_url=null and every layout fell back to
streaming the full original from the NAS via the secure-image route.
With ~100 NAS-mounted photos that meant minutes of wall-clock load time,
sequential per tile.
Two halves:
1. import-external route generates the thumbnail right after each
successful insert and writes thumbnail_path on the row. Best-effort:
a single failure logs a warning and leaves thumbnail_path=NULL —
ensureThumbnail will retry lazily on first view. Synchronous in the
loop adds ~100-300ms per image; for the worst-case 1000-photo import
that's still under the typical request timeout.
2. ensureThumbnail() in imageProcessor handles external photos too —
resolves the local NAS mount path via resolvePhotoFilePath instead of
the storage-backend key. This covers existing externals already in
the database that were imported before this fix: first gallery view
per photo regenerates the thumbnail, subsequent views are fast.
Filename-collision protection: external thumbnails use
`thumb_ext<photoId>_<basename>` so two events both referencing
e.g. `IMG_0001.jpg` on different NAS subtrees can't clobber each other's
thumbnail. generateThumbnail accepts a new options.outputBasename to
support this without changing the managed-photo behaviour.
Verified locally with a 3-photo external dir and a real NAS-style import:
POST /api/admin/external-media/events/N/import-external
→ {imported:3, thumbnailsGenerated:3, thumbnailsFailed:0}
/api/gallery/<slug>/photos returns thumbnail_url for every photo
Lazy-regen path: clearing thumbnail_path + deleting the file, then
hitting /thumbnail/N regenerates and repopulates the row in 42ms.
Closes#423.
The "Send Test Email" button on the Update Notifications settings page
called sendUpdateNotificationNow() — which bailed out with "No updates
available" when the instance was already on the latest version. Admins
on a current install had no way to verify their SMTP / recipient list
was working until an update happened to be pending. Reported in #418
by @Rekoo-PS.
Changes:
- Add migration 087: insert a dedicated `version_update_test` email
template (EN + DE, matching the existing version_update_available
convention) with copy that reads as a config-check rather than as a
real update notice. Subject prefixed with [TEST] so it's unambiguous
in the inbox. Variables: current_version, channel, recipient_email.
- Replace sendUpdateNotificationNow() with sendTestUpdateNotification()
in updateNotificationService.js. The new path:
- Always sends — no updateAvailable bail-out.
- Uses the version_update_test template.
- Falls back gracefully if checkForUpdates fails (so a transient
GitHub API hiccup doesn't block a config-check email).
- Does NOT update last_notified_version — that field stays owned by
the real-update path so a test send doesn't shadow a future
genuine notification for the same version.
- Wire /admin/system/updates/notifications/send to the renamed function.
No frontend change needed (the button already calls this endpoint).
Verified locally with the dev mailhog: clicking Send Test Email on a
3.42.3-beta.0 instance (which has no pending update) delivers 4 emails
to all admin recipients with subject "[TEST] PicPeak Update Notification
— configuration check" and body interpolated correctly. Returns
{success: true, successCount: 4, ...} — previously would have returned
{success: false, message: "No updates available"}.
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).
Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.
Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.
i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.
Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
CreateEventPage's branding-default effect used a boolean ref guard that
locked in whichever theme_config arrived first. React Query can hand the
observer a cached (stale) copy on initial render and then push fresh data
once the network call resolves — the boolean ref meant the form kept the
stale theme and ignored the fresh one.
Replace the ref with a stringified-hash check: re-apply when the source
actually changes (including stale → fresh) but skip when nothing has.
User edits via the customizer aren't disturbed because settings.theme_config
only refreshes on a real Branding save, not on form state.
This unblocks the local pre-push smoke gate's 07-branding-default test,
which was test.fixme'd against this exact React Query staleness.
Triage of an external SAST/SCA scan run on 2026-05-06. Most loud findings
were already resolved by PR #412 (the 18-CVE backport); this PR addresses
the residual real items:
* Drop unused `handlebars` from backend deps. The runtime require was
removed in PR #367 (#367) but the package.json line stayed. handlebars
was the source of two flagged criticals (CVE-2026-33937 RCE,
GHSA-2w6w-674q-4c4q AST injection) plus 8 highs — all now gone.
* `npm audit fix` on backend + frontend. Bumps transitive picomatch,
flatted, postcss, brace-expansion via lockfile, and direct dompurify,
lodash, vite, i18next-http-backend within their existing semver ranges.
Both audits now report 0 vulnerabilities.
* Add `event.origin === window.location.origin` check to the THEME_PREVIEW
message listener in PreviewPage. The branding page posts from the same
origin, so nothing legitimate is rejected; without the check, any third
party that window.open()'d the preview could push arbitrary
branding/theme payloads (semgrep
insufficient-postmessage-origin-validation).
* nginx: `proxy_hide_header` for X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Content-Security-Policy, Permissions-Policy,
Strict-Transport-Security at server level. nginx adds these itself, but
helmet on the backend was also emitting them — clients were seeing
duplicates (testssl flagged "Multiple X-Frame-Options / CSP /
Permissions-Policy / Referrer-Policy headers" on the live origin).
Single source of truth now.
* Dockerfile hardening (checkov):
- HEALTHCHECK on backend/Dockerfile, backend/Dockerfile.dev,
frontend/Dockerfile.dev. Frontend production Dockerfile already had
one.
- USER node in frontend/Dockerfile.dev (was running as root).
* GitHub Actions docker-build.yml: explicit top-level
`permissions: contents: read`. Per-job blocks already declare
`packages: write` where needed; this stops future steps from
inheriting unintended privileges (CKV2_GHA_1).
Backend npm audit: 4 vulns -> 0.
Frontend npm audit: 6 vulns -> 0.
Backend unit tests: 13 suites, 131/132 passing (1 pre-existing skip).
Frontend type-check + lint: clean.
The pre-existing integration-test failures (live DB / S3 required) and
the ThemeCustomizerEnhanced QueryClientProvider failures are unrelated
and reproduce on origin/beta without these changes.
Closes the open Trivy code-scanning alerts for app-side dependencies.
The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch,
brace-expansion, ip-address inside the Node image itself) are deferred
to a separate Node-base-image PR — they're build-environment-side and
need their own compatibility testing.
## Direct dependency bumps
| Package | From | To | CVEs cleared |
|---|---|---|---|
| axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 |
| nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 |
| i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 |
| uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 |
| postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 |
## Transitive bumps (npm overrides)
For transitives whose direct parents haven't released a version that
picks up the patched range, pinned via npm overrides:
| Package | Min | CVE |
|---|---|---|
| follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 |
| fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 |
| @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 |
| ip-address (backend) | >=10.1.1 | CVE-2026-42338 |
## Why axios is now safe to bump past 1.14.0
PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain
attack on a specific compromised version range. The 1.15.x series
are post-incident upstream releases — clean. Confirmed with the
maintainer before bumping.
## Verified
* `npx tsc --noEmit` (frontend) — clean
* `npx vite build` (frontend) — clean (~4s, existing bundle-size
warning, not new)
* Backend module-load smoke test — all critical modules load
(`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`,
`storage`) with the new axios + nodemailer
* Lockfile re-verification — every targeted CVE now resolves to
the patched version range
## Remaining out of scope
* npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` —
picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion
CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These
live in the Node base image and require a Node base image bump
with its own compatibility testing — separate PR.
Targeting `beta` so the bumps go through the normal release-please
flow before promotion to `main`.
Two follow-ups from PR #401's review:
1. Download button text was hardcoded `color: '#ffffff'`. Once admins
start picking palettes via #400's expanded customizer, a pale accent
(yellow, pastel blue, etc.) leaves the button unreadable — white
text on near-white background.
Fix: derive the foreground colour from the accent's WCAG relative
luminance and expose it as the new `--color-accent-fg` CSS variable
in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black
text; dark backgrounds get white. Same treatment applied to
`--color-accent-dark-fg` for the filled-CTA token.
The Download button now reads `var(--color-accent-fg, #ffffff)` so
any future component that paints on accent gets the same treatment
for free, and legacy deployments before the variable is set fall
back to the previous hardcoded white.
Threshold-based (rather than "highest contrast ratio") to preserve
how saturated mid-tone accents have always rendered. The Picpeak
default green (#5C8762, L≈0.20) keeps white text — same visual
identity as before. Only genuinely pale accents flip to black,
which is the actual scenario the review flagged.
2. The Download button JSX was duplicated three times in
GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines
each). Extracted into a small inline `HeaderDownloadButton`
component above the GalleryLayout export. Three call sites now
collapse to a 5-line component invocation each. Markup,
accessibility, and styling live in one place — future tweaks
only need to happen once.
## Files
- `frontend/src/utils/contrast.ts` — new helper module:
`relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and
`getReadableForeground(hex)` (white-or-black picker).
- `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases:
fallbacks, saturated mid-tones, pale accents, near-black,
shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors
(black/white luminance).
- `frontend/src/contexts/ThemeContext.tsx` — wire the helper into
`applyTheme`: set `--color-accent-fg` from `accentColor` and
`--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`.
- `frontend/src/components/gallery/GalleryLayout.tsx` — extract
`HeaderDownloadButton` component above `GalleryLayout`, replace
three inline button blocks with the component, update its inline
style to read `--color-accent-fg` (with the legacy `#ffffff` as
the CSS-variable fallback).
## Verified
- `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass
- `npx tsc --noEmit` — clean
- `npx eslint` clean on every touched file
- Default PicPeak green still renders white text (no regression)
- Pale accent (#fef9c3 yellow-100) now correctly renders black text
Addresses the-luap/picpeak#386 — gallery header layout cleanup.
- Drop the redundant "Menu" text label; menu button is icon-only with
tight padding (p-2).
- Absolute-position the menu icon at the very left of the header so it
no longer pushes the logo right with every other action. Logo wrapper
picks up pl-12 sm:pl-14 only when a menu button is rendered, so the
icon and logo don't overlap. When no menu button (controlsStyle:
classic), logo is flush with .container.
- New accent-coloured "Download" CTA placed immediately left of Logout.
Always visible when downloads are allowed; replaces the previous
primary-coloured "Download All" header button. Same CTA appears in
standard, hero, and minimal headers. Intentionally NOT shown in the
no-header variant (chromeless by design).
- Coloured via var(--color-accent) inline so the button automatically
tracks whatever palette the admin has chosen — works on plain beta
today (#22c55e) and auto-upgrades to the CI accent when #400 lands.
The sidebar's own Download All is untouched. Old showDownloadAll prop
stays on GalleryLayout for back-compat; GalleryView now passes
showDownloadAll={false} so only the new accent button renders in the
header.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Third loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns
after a server restart or after an idle gap longer than the configured
session timeout.
## Root cause (server)
`sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It
rejects with `401 SESSION_TIMEOUT` when either:
- the in-memory `lastActivity` for the token is older than the timeout, or
- this is the first request with this token AND the token's `iat` is
older than the timeout (post-restart guard).
`/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`,
so the middleware never runs for it. Result: an idle/old-iat admin token
returns `valid: true` from `/auth/session` while every protected
endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's
401 interceptor hard-redirects to `/admin/login`, `/auth/session` says
valid again, loop closes — exact same shape as the previous two
asymmetries the symmetry pass missed.
Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to
`middleware/sessionTimeout.js` that reads the same in-memory map and
applies the same lastActivity / iat-vs-timeout logic as the middleware,
without updating the map (the middleware is the only place that records
activity; `/auth/session` is read-only by design). `/auth/session`
calls the helper for `decoded.type === 'admin'` after the existing
admin-existence and password-change checks. Same try/catch fall-through
pattern as the prior fixes so a missing/broken helper doesn't fail-closed
during early bootstrap or in test stubs.
## Root cause (client race amplifying the loop)
Even with the server fix, the previous `useSessionTimeout` hook called
`AdminAuthContext.logout()` which dispatches `POST /auth/logout`
fire-and-forget AND has its own `finally { window.location.href }`,
then immediately set `window.location.href = '/admin/login?session=expired'`
on top. Two consequences:
- The cookie wasn't reliably cleared before the new page loaded —
if any /auth/session asymmetry slipped through, the loop replayed
inside the same tab. New-tab and "refresh several times" "fixes"
were just the logout request eventually completing.
- Two redirects raced; sometimes the `?session=expired` query was
dropped, breaking the login-page toast.
Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie
is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly
instead of going through AdminAuthContext.logout (which has the
side-effect redirect we don't want), and (c) navigate exactly once
with the `?session=expired` query.
## Tests
- `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under
a `session-timeout symmetry` describe block: helper says expired →
valid:false; helper says active → valid:true; helper not called for
gallery tokens; helper throws → fall through to valid:true (defensive).
Existing 9 tests still pass (mock now includes
`isSessionExpired: jest.fn(() => Promise.resolve(false))` as the
default).
- `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7
new unit tests for the helper itself: fresh token / old-iat /
recently-active / null-input / no-mutation / 60-min default
boundary cases.
20 cases total, all green. Lint clean on every touched file.
Two issues in the fonts service test suite added by #390 — the behaviour
assertions all passed, but 5 of 24 tests had assertions that silently
no-op'd, so any regression in those code paths would not have been
caught.
## Issue 1: jest.resetModules() bypassed the logger mock
`beforeEach` called `jest.resetModules()` then re-required `fontsService`.
After resetModules, the `jest.mock('../../src/utils/logger', ...)` factory
at the top of the file no longer applied to subsequent requires — so the
freshly-required `fontsService` captured the REAL logger while the test
file's `logger` variable still pointed at the mocked one. The 4
"warning logged" / "info logged" assertions resolved as 0 calls and
silently passed-as-noop.
The resetModules call wasn't necessary in the first place — module-level
state in fontsService is just the cache, which clearFontsCache() already
resets. And both getBundledFontsRoot() and getUserFontsRoot() read
process.env at call-time, not at module load, so the env vars set in
beforeEach are picked up without needing a fresh require.
Fix: require fontsService once at module top (inside the jest.mock
hoisting scope) and drop resetModules + the per-test re-require.
## Issue 2: case-insensitive filesystem (macOS / Windows)
The "case-insensitive duplicate within the same root" test created
`Inter/` and `INTER/` to trigger the dedup warning. On a case-sensitive
FS (Linux ext4) both directory entries exist and the dedup branch fires;
on macOS APFS or Windows NTFS the second mkdir resolves to the same
folder as the first, so only one ever exists and the dedup is
unreachable from this test setup. Test failed on macOS dev, passed on
Linux CI.
Fix: probe at load time by creating a lowercase file and checking if
its uppercase variant resolves to the same inode, then conditionally
test.skip the affected test on case-insensitive hosts. Comment in the
test body explains why.
## Result
23 of 24 tests now pass on macOS; the case-sensitive-only test runs on
Linux CI. All previously-no-op'd assertions now exercise their code
paths.
The Acknowledgments block had a generic "thanks to all contributors"
line but no actual recognition by name. Two people in particular have
moved the project meaningfully forward and should be called out:
- @Luca-Timo — code contributor across multi-arch Docker, the external-
URL CMS toggle, folder tree picker, admin email picker, self-hosted
webfonts, the gallery header/banner decoupling, and typed-API
refactors. Consistent quality.
- @Rekoo-PS — bug reporter and feedback loop. Filed the issues that
drove the login-loop fix, gallery loading skeleton, redirection
cleanup, mobile lightbox overhaul, admin events search-counter fix,
photo-count column, and bulk-delete workflow. Also a BuyMeACoffee
supporter.
Closes the implicit recognition gap and sets up the section so future
contributors can be added with a one-line PR.
Adds the bulk-delete half of #384 — admins can select multiple
events from the list and delete them in one batch, gated by
re-entering their password.
## Why password confirmation
Bulk delete is destructive and irreversible (cascades across 5 DB
tables and 3 filesystem paths per event). Re-entering the password
matches the pattern already used by /auth/admin/change-password and
makes accidental clicks much harder than a plain "type DELETE to
confirm" — the muscle-memory required to type your real password is
a stronger gate than typing a literal word.
## Changes
### Backend (adminEvents.js)
- Extracted the per-event cascade-delete logic into a module-private
`deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id
route now calls it instead of inlining 60 lines of cascade — same
behaviour, no drift between the per-event and bulk paths.
- New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`.
Permission: `events.delete`.
- Validates `eventIds` array length (1–100) and that each id is an
integer. The 100-cap keeps request time bounded; the per-event
cascade touches DB + filesystem so 1000 events at once would risk
timing out the request.
- Verifies `password` against the calling admin's bcrypt hash via
`bcrypt.compare()` (same as /auth/admin/change-password). Wrong
password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no
events are touched.
- Loops via `deleteEventCascade`, returns
`{ results: { successful, failed } }` with the same shape as
/bulk-archive so the frontend can show partial-failure feedback.
- Logs `bulk_delete_completed` activity with totals.
### Frontend
- `events.service.ts`: `bulkDeleteEvents(eventIds, password)`.
- New `BulkDeleteModal.tsx`. Red/destructive variant of the
bulk-archive modal:
- Lists the events to be deleted (so the admin can verify).
- Password input with show/hide toggle, autofocus, Enter-to-submit.
- Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD
response without losing the modal state — admin can retry
without re-typing the event list.
- "Processing" state replaces the form with a spinner + "Deleting
N events. This may take a few minutes — please don't close this
window." (i18n) so admins know not to abandon the page during
a slow operation.
- `EventsListPage.tsx`: "Delete Selected" button next to "Archive
Selected" in the bulk-actions bar (red-styled to signal danger),
bulkDeleteMutation that maps the 401 to the modal's inline error
and any other failure to a generic toast.
### i18n
12 new keys under `events.bulkDelete.*` in all 5 locales
(en/de/nl/pt/ru): title, warning, password label/placeholder/help,
submit, processing, incorrectPassword, successAll, successPartial,
errorGeneric, plus `events.deleteSelected` for the button. Hand-
written for de; nl/pt/ru should get a native-speaker pass at some
point but read naturally.
### Verified
- `npx tsc --noEmit` clean
- `npx eslint` clean on every touched file (4 pre-existing errors in
adminEvents.js for unused vars unrelated to this PR)
- All 5 locale JSON files parse cleanly
- `node -e "require('./src/routes/adminEvents')"` loads the module
Closes the bulk-delete half of #384. The Photos-column half lands
separately in PR #387.
The admin events table didn't surface how many photos each event
contained — admins had to click into the event to find out. The
backend already computes `photo_count` for every row in the
GET /admin/events list response (adminEvents.js:794-796), so this
is a frontend-only display change.
- Insert a "Photos" column between Date and Status — groups with
the "what's in this event" info.
- Right-aligned, tabular-nums for clean numeric alignment in the
column.
- Reuses the existing `events.photos` i18n key already shipped in
all 5 locales for the EventDetailsPage tab list ("Fotos" / etc.) —
no new translations needed.
- Updates the empty-state colSpan from 7 to 8.
Closes the column-add half of #384. The bulk-delete request from
the same issue lands separately.
Follow-up to PR #378 — drops the (e: any) / (d: any) casts in the
external-folder-tree picker. ExternalEntry is already exported from
externalMedia.service.ts; the call site just wasn't using it.
- Import the type alongside the service.
- Annotate the dirs filter callback so `e.type` is the union 'dir' |
'file' instead of any.
- Drop the (d: any) annotation from the map — TypeScript infers
ExternalEntry from the typed `dirs` array.
No behaviour change, no test impact. `npx tsc --noEmit` clean,
`npx eslint` clean.
Video uploads on production fail with "missing ffmpeg" because the
backend container ships nothing usable for the video pipeline.
Two compounding causes:
1. **Alpine + glibc mismatch.** The npm `@ffmpeg-installer/ffmpeg`
dependency added with the video-support PR (commit 68a9dc5)
ships per-platform binaries via optionalDependencies. The Linux
binaries are built against glibc, but the backend image runs on
`node:22-alpine` (musl libc) — known to either fail to execute
or fail on shared-library lookups on Alpine.
2. **`ffprobe` missing entirely.** `@ffmpeg-installer/ffmpeg`
bundles only the `ffmpeg` binary. There's a separate
`@ffprobe-installer/ffprobe` package that the codebase never
depended on. But `videoProcessor.js:21` calls
`ffmpeg.ffprobe(videoPath, …)` — the very first step of the
video pipeline shells out to a `ffprobe` binary that doesn't
exist in the image. Even if (1) worked, every video upload
would 500 here.
The fix is to install Alpine's `ffmpeg` package via apk. It ships
both `ffmpeg` and `ffprobe` built natively against musl, ~70MB
extra image size, single line in the Dockerfile, no per-arch
handling needed (apk pulls the right binary for both linux/amd64
and linux/arm64 — works with the multi-arch infra from #349).
- `backend/Dockerfile`: add `ffmpeg` to the apk install line.
- `backend/Dockerfile.dev`: same for dev parity.
- `backend/src/services/videoProcessor.js`: remove the
`setFfmpegPath(require('@ffmpeg-installer/ffmpeg').path)` line
— without removing it, fluent-ffmpeg would prefer the broken
bundled binary over the working apk one. Letting fluent-ffmpeg
fall back to PATH lookup picks up the apk binary in the
container and the developer's locally-installed binary on dev
hosts (Homebrew on macOS, apt on Debian).
- `backend/package.json`: drop the now-unused
`@ffmpeg-installer/ffmpeg` dependency. `npm install` removes
2 packages from the lockfile.
Verified: `videoProcessor.js` still loads cleanly (`node -e
"require('./src/services/videoProcessor')"`); lint clean.
Two follow-ups to PR #372 (external-URL toggle for imprint /
privacy CMS pages):
1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de
locales but the project ships 5 locales total. Adds the missing
nl / pt / ru translations so the admin CMS page renders in the
active language for those users instead of falling back to
English literals next to the German/Dutch/Portuguese/Russian
surrounding strings.
2. **API shape.** `publicCMS.js` returned `external_url`
unconditionally — even when `use_external_url` is false the URL
value was still emitted in the public response. The frontend
correctly gated on both flags so it worked, but the API surface
was leaking a value the admin had explicitly disabled. The
value still lives in the DB (so the toggle can be flipped back
on without losing it), but the public endpoint now returns
`null` whenever the toggle is off.
Note: kept the existing `logo_url` shape unchanged. Its semantics
are different — null means "fall back to global branding" and
consumers rely on always having the field, so emitting it
unconditionally is intentional there.
No frontend change needed: both `GalleryLayout` and `LegalPage`
already gate on `use_external_url && external_url`, so the
short-circuit handles `external_url: null` correctly.
The rebuilt modal in this PR shipped with hard-coded English strings.
That made the reset flow untranslated for German/Dutch/Portuguese/
Russian customers — toasts, confirm dialog, success screen all
fell back to English regardless of the active locale.
- New `events.passwordReset.*` namespace in en/de/nl/pt/ru with 22
keys covering both modal screens, the warning banner, validation
errors, and the toast messages.
- Modal uses `useTranslation()` for every previously hard-coded
string. Reuses `common.cancel`, `events.copy`, `events.copied`
where they already exist across all locales.
- The {{eventName}} interpolation uses i18next's standard variable
syntax so the description line reads naturally in each language.
No behaviour change. TypeScript clean (`npx tsc --noEmit`), ESLint
clean. JSON validity checked for all 5 locale files.
Two related defects on the same gallery-email surface that PR #367
opened, addressed together:
1. Reset-password endpoint was a one-way auto-generate.
`POST /admin/events/:id/reset-password` always called
`generateReadablePassword()` and ignored any client-supplied value;
the modal only offered a confirm + a forced auto-generated result.
Admins who wanted to set a memorable customer-supplied password
had no way to do it.
Backend: route now reads optional `password` from the body. If
present, validates with `validatePasswordInContext('gallery', …)`
(same rules as create-event) and uses it; if absent, falls back to
the existing generator, so old callers / cron stay functional.
Switched the bcrypt rounds from a hard-coded `10` to
`getBcryptRounds()` to match the create flow.
Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with
show/hide, confirm-password field that appears on type, the same
`<PasswordGenerator>` used by `CreateEventPage` (event-context-
aware, fills both fields when used), send-email checkbox,
client-side validation, server-side validation feedback inline.
Submit empty → server auto-generates and the success screen shows
the value with a copy button (legacy one-click flow preserved);
submit with a typed password → success toast + close (no need to
re-show what the admin already typed).
Service layer: `events.service.resetPassword(id, sendEmail,
password?)` only sends `password` in the body when set.
Caller: `EventDetailsPage` now passes `eventDate` + `eventType`
into the modal so the generator has event context.
2. `gallery_link` was the path-only `event.share_link` in three
email-queue sites, so customer mail showed
`/gallery/<slug>/<token>` instead of the full
`https://example.com/gallery/<slug>/<token>` URL.
- `adminEvents.js` reset-password queue (#1437)
- `adminEvents.js` resend-creation-email queue (#1502)
- `expirationChecker.js` expiration_warning queue (#82)
All three now derive `shareUrl` from `buildShareLinkVariants`
(the same helper already used by create-event, publish-from-
draft, and event-rename). The other 4 callers
(`adminEvents.js:651/913`, `events.js:187`,
`eventRenameService.js:231`) already used the full URL — this
closes the gap.
Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on
every touched file (the 4 lint errors that remain in
`adminEvents.js` are pre-existing and predate this branch).
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.
Renderer (`backend/src/services/emailProcessor.js`)
- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
before flat `{{var}}` substitution. The shipped templates have used
Handlebars-style conditionals since migration 026; the renderer
ignored them, so the markers leaked verbatim into every mail with
an empty welcome_message. Lifted to module scope and exported so
the conditional contract is unit-testable. Single-pass, non-nested
(commented).
- Added `passwordSetAtCreationI18n` next to the existing two i18n
password sentinels so `(set at creation)` (sent by the publish-
from-draft flow when only the bcrypt hash remains) is localised
to "Das bei der Erstellung der Galerie gesetzte Passwort" /
equivalent in EN/DE/NL/PT/RU instead of the raw English string.
- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
so admin-supplied free text (`event_name`, `host_name`, …) is
HTML-escaped on substitution into the HTML body. Allowlist of
passthrough keys (`welcome_message` already-HTML, server-generated
URLs `gallery_link` / `client_link`). Subject and text body keep
the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
before nl2br so the welcome_message allowlist is safe.
- New `htmlToText()` strips `<style>` and `<script>` blocks (and
their content) before tag-stripping, decodes common entities, and
collapses whitespace. Used by the textBody fallback in
`sendTemplateEmail` — without this, every template missing a
`body_text` produced a "plain-text" mail starting with the 100+
lines of CSS embedded by `wrapEmailHtml()`.
- The client-access section (#172) now mirrors its HTML block into
`textBody` using the same per-language strings, so plain-text
recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
into `clientAccessI18n` (RU uses ПИН-код).
- Added `getSupportEmail()` exported helper that reads
`branding_support_email` from `app_settings` (JSON-decoded), with
the SMTP from-address as fallback. Used by the gallery_expired and
archive_complete callers below.
- Removed dead `require('handlebars')` (unused since the regex
renderer landed; pre-existing lint error in this file).
Callers (data the templates already reference)
- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
(templates use this, the old code sent `expiration_date` —
typo'd key, never read), drop the hard-coded `.de`/`en` sniff
(the processor formats with the recipient's resolved language),
add the `{{password_security_message}}` sentinel for
`gallery_password` (plaintext is gone by warning time, so
customers used to see literal `{{gallery_password}}` in the mail).
- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
now supply `host_name`, `event_date`, `expiry_date`,
`support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
doesn't render literal `{{host_name}}, your gallery expired on
{{expiry_date}}`. Skip the duplicate admin send when
admin_email == customer_email.
- `archiveService.js`: `archive_complete` queue now supplies
`host_name`, `photo_count` (from `photoEntries.length`),
`archive_date`, `support_email` — the previous payload had only
`event_name` and `archive_size`, so most of the mail was
unfilled placeholders.
Tests
- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
16 cases — flat substitution, conditional truthy/falsy/missing/
multi-line/sibling/numeric-0, plus 5 cases for the new
`escapeHtml` option (default off, escape on, allowlist
passthrough for welcome_message and gallery_link).
- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
the regression scenario (full wrapped body with embedded `<style>`
block), tag-stripping, entity decoding, paragraph spacing.
- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
`nl2br`, and the now-escaping `formatWelcomeMessage`.
35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
Cover the two new pieces of the async pipeline:
backgroundProcessor.claimNextPhoto
- returns null when no pending rows
- returns the row + flips status under postgres FOR UPDATE SKIP LOCKED
- returns null when SQLite UPDATE-with-guard loses the race
- returns the row when the SQLite guard wins
photoProcessor.processPhoto
- happy path: writes thumbnail / dimensions / EXIF capture date and
marks 'complete'; fires watermark queue + photo.uploaded webhook
with the right payload
- video path: writes ffmpeg duration / codec / dimensions; does NOT
queue watermark (image-only)
- throws cleanly when the photo row no longer exists
Mocks db / imageProcessor / videoProcessor / storage / sharp /
watermarkGeneratorService / webhookService / logger so the tests run
without a real DB or any image library calls — fast and deterministic.
Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.
services/uploads.service.ts (new)
- getStatus(uploadId) — JSON snapshot from /admin/uploads/:id/status
- retryPhoto(photoId) — POST /admin/photos/:id/retry
- streamUrl(uploadId) — SSE upgrade URL
hooks/useUploadProgress.ts (new)
- Tracks N concurrent upload IDs (one per chunk POST) and merges
counters into a single aggregate.
- Always polls every 1.5s; opportunistic SSE upgrade on top of that.
SSE failure (proxy buffering, etc.) silently downgrades to polling
only — no reconnect storms.
- Auto-stops both channels when every tracked group is in a terminal
(complete/failed) state.
components/admin/PhotoUpload.tsx
- Captures upload_id from each chunk's 202 response, feeds them into
useUploadProgress.
- Phase machine extended: stays in 'processing' until the worker
drains the queue (not just until bytes-on-wire). Progress UI shows
real "X of N done" with a determinate bar fed by the aggregate.
- "You can leave this page" hint kept — closing the modal is now
actually safe, work continues server-side.
- Side-effect refactor: invokes onUploadComplete twice — once early
so the user sees photos appearing immediately, once on terminal
so the parent grid sees final state.
components/admin/AdminPhotoGrid.tsx
- Photos with processing_status pending/processing render an amber
placeholder card with a spinning Cog instead of the missing
thumbnail.
- Photos with status='failed' render a red card with the error message
and a "Retry" button that POSTs /admin/photos/:id/retry.
pages/admin/EventDetailsPage.tsx
- Photo list query gains refetchInterval that polls every 2s while
any photo is non-terminal, then stops. Keeps the grid auto-fresh
during ongoing processing.
Move thumbnail / EXIF / dimensions / watermark / webhook work off the
upload request thread and into a background worker pool. Upload
requests now return 202 in seconds even on NFS-backed storage; the
worker(s) drain the pending queue independently and update each
photo's processing_status to 'complete' or 'failed' on its own.
Schema (migration 085_async_photo_processing.js):
- photos.processing_status enum default 'complete' (existing
rows are already done)
- photos.processing_error populated on 'failed'
- photos.processing_started_at timestamp for janitor recovery
- photos.upload_id groups all photos from one upload
request so the frontend can poll
status by group
- indexes on processing_status and upload_id for queue lookups
services/photoProcessor.js
- queueFilesForProcessing(files, options) — shared helper used by
the admin and gallery upload routes. Moves files to final storage
+ inserts pending rows; returns { uploadId, photos, errors }.
- processPhoto(photoId) — worker-mode: reads original from storage
via withLocalCopy (transparent local/S3), generates thumbnail and
EXIF/dimensions or video metadata, queues watermark, fires
photo.uploaded webhook, marks 'complete'. Throws => caller marks
'failed' with the error message.
- processUploadedPhotos kept untouched — chunkedUploadService still
uses the synchronous path.
services/backgroundProcessor.js (new)
- N independent worker loops per backend instance (default 2,
UPLOAD_PROCESSOR_CONCURRENCY env override).
- Multi-pod safe: postgres SELECT FOR UPDATE SKIP LOCKED, sqlite
UPDATE-with-status-guard. Pods race on rows, exactly one wins.
- Janitor every minute resets photos stuck in 'processing' for >10
minutes (worker died, pod restarted) back to 'pending'.
- UPLOAD_PROCESSOR_DISABLED=true opt-out for CI/test.
- Started from server.js after the other long-running workers.
routes/adminPhotos.js — POST /:eventId/upload
- Replaced batch-of-25 sync processing loop with per-file
move-to-storage + insert-pending. Response is now 202 with
upload_id, count, photo_ids in addition to the legacy
successCount / replacedCount fields the existing frontend reads.
- Per-request temp directory cleanup is now a single idempotent
handler on res.finish/res.close (was three inline blocks for
error paths only, leaking dirs on success — original bug from
contributor analysis).
- GET /uploads/:upload_id/status — JSON snapshot of pending /
processing / complete / failed counts plus per-photo state.
- GET /uploads/:upload_id/stream — SSE upgrade. Polls internally
every 1.5s, emits on snapshot change, ends when all photos
reach a terminal state.
- POST /photos/:photoId/retry — flips a 'failed' photo back to
'pending' so the worker picks it up again.
- GET /:eventId/thumbnail/:photoId now returns 503 with Retry-After
while the photo is still pending/processing, and 422 on 'failed'.
The admin grid renders placeholders accordingly.
routes/gallery.js — POST /:eventId/upload (guest)
- Refactored to use queueFilesForProcessing instead of the synchronous
processUploadedPhotos. Same 202 + upload_id shape.
- GET /:slug/photos now filters processing_status to 'complete' (or
NULL for pre-migration rows) so guests never see in-flight photos.
Side-effect timing change:
- photo.uploaded webhook now fires from the worker after the photo
is actually processed (thumbnail + dimensions populated) instead
of from inside the upload request. Same payload fields. Worth a
one-line note in the changelog.
Phase 1 of the upload-progress redesign. Two changes that ship UX wins
without any architectural surgery — they're a stepping stone for the
full async-processing rework that follows in subsequent commits.
1. Two-state progress bar (PhotoUpload.tsx, UserPhotoUpload.tsx)
When axios.onUploadProgress reports loaded === total, the request is
on the server and the bytes have left the browser. Today the bar sits
at 100% for the chunk while the backend runs sharp/ffmpeg/EXIF (often
minutes on NFS-backed storage) and users assume the upload froze.
The component now distinguishes two phases:
- 'transferring' — bytes-on-wire, determinate progress bar.
- 'processing' — bytes done, waiting for response. Indeterminate
spinner + an explanatory hint that the backend is
generating thumbnails / reading metadata and the
user can leave the page.
Same pattern in UserPhotoUpload (gallery): the per-file checkmark
icon is replaced by a Loader2 spinner while the request is in flight
after bytes-on-wire finished.
2. Temp directory cleanup (adminPhotos.js)
Multer creates temp/upload_<ts>_<rand>/ per request. Files inside it
are individually unlinked after they're moved to storage on the
success path, but the empty directory was never removed. On error
paths three different inline blocks each tried to clean up; the
success path was missed entirely. Result: the orphan-empty-dirs
accumulation reported in the issue (70+ on the affected instance).
Replace the inline cleanup blocks with a single idempotent
cleanupTempDir() registered on res.finish + res.close, so it fires
exactly once on every exit path (validation 4xx, server 5xx, multer
error, success).
New translation keys (en/de): upload.transferring, upload.processing,
upload.processingHint, upload.processingProgress, upload.processingFailed,
upload.retryFailed.
Second loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355. The frontend trusts /auth/session as the source of
truth for "is the user authenticated?". When that endpoint is more
lenient than the protected middleware, every admin endpoint 401s
right after /auth/session said valid:true, the response interceptor
hard-redirects to /admin/login, /auth/session says valid again, and
the cycle closes — exactly the loop reported on v3.32.4-beta.0.
#355 fixed the issuer-claim asymmetry. This commit fixes the
remaining asymmetries: /auth/session was missing the admin-existence,
admin-active, password-change-after-iat, and gallery-existence /
gallery-archived / gallery-expired checks that adminAuth and
galleryAuth perform on every protected request.
The fix is to mirror those checks in /auth/session, scoped by token
type, and degrade gracefully when the underlying tables aren't
present (test fixtures, early bootstrap) so the endpoint never
fails-closed because of a missing table.
Reproducer that the new test covers:
1. Admin logs in (token issued at T).
2. Admin (or another admin) changes their own password at T+1.
3. Browser still has the cookie from T.
4. /auth/session says valid:true (no password-change check).
5. /admin/dashboard fires queries; adminAuth rejects with
PASSWORD_CHANGED 401.
6. Frontend redirects to /admin/login.
7. /auth/session says valid:true again. → loop.
Other surfaces this also covers:
- admin user deactivated (admin_users.is_active = false)
- admin user deleted
- gallery token whose event is archived
- gallery token whose event has expired
Tests live in __tests__/routes/authSession.symmetry.test.js — 9 cases,
mocking db / tokenRevocation / tokenUtils / recaptcha / sessionTimeout
so the suite runs without a real database.
The root devDependencies still pinned an older Playwright (1.48.2)
plus a stray `dotenv` that nothing in the e2e suite or root scripts
actually requires (verified via grep across tests/). Updates the
Playwright version to match the current upstream stable and removes
the unused dotenv to keep the root install lean.
Originated from a local stash that picked up these changes; landing
them as a small dedicated commit so they don't blend into the auth
fix that follows.
Two further fixes for the gallery loading sequence shown in
@Rekoo-PS's frame breakdown on issue #358 — both about colours that
didn't track the active theme.
1. Initial white frame (frame f1)
The pre-React bootstrap script in #359 sets the cached background
on documentElement, but the browser may paint the very first frame
*before* that <script> tag runs (synchronous parse-time JS in the
<head> is still slightly later than CSS apply-time). On first-visit
dark-OS devices that meant a single white frame before the script
resolved.
Fix: move the OS-preference default into a <style> block that
precedes the script. CSS @media (prefers-color-scheme) is applied
before paint, so dark-OS devices land on dark from frame zero.
The script keeps the per-gallery cache hit on top, and now also
stamps the colour onto document.body in case the body element has
already mounted by the time the script runs.
2. "Most annoying" skeleton tile frame (frame f4)
Skeleton placeholders rendered as bright `bg-neutral-200` light grey
regardless of theme. On a dark gallery that's the highest-contrast
thing on screen during loading — the exact frame Rekoo-PS labelled
"the most annoying" in the issue.
Fix: the Skeleton component's background now reads
`var(--color-surface-border)`, which ThemeContext already wires up
per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per-
event themes can override). The bare `<div>` no longer carries any
colour utility class — the inline style supplies the active value.
Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable
in favour of `var(--color-surface)` for the same reason.
Tests:
New src/components/common/__tests__/Skeleton.test.tsx covers
- Skeleton uses var(--color-surface-border, ...)
- bg-neutral-200 is no longer present
- SkeletonGalleryGrid tiles all inherit the theme colour
- SkeletonCard surface uses var(--color-surface)
Two settings with overlapping names but different value sets were being
conflated:
- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position (hero block, vertical): 'top'|'center'|'bottom'
getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.
Fix:
1. Drop the bogus mapping. branding_logo_position is no longer read by
getBrandingDefaults — it doesn't belong there. The fallback default
('top') is used unless the request body explicitly provides
hero_logo_position, which is independently validated.
2. Migration 084_fix_hero_logo_position normalises any existing rows
whose hero_logo_position is outside ('top','center','bottom') back
to 'top'. Without this, affected events would continue to 400 on
every save until the admin manually picks a valid option.
Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
Opening a gallery with a dark theme briefly painted a white background
between the initial HTML render and React applying the per-event theme.
The HTML shipped with no theme info, so the first paint used the
default (#fafafa) before /gallery/:slug/info resolved.
Two-part fix.
1. Inline bootstrap script in index.html runs synchronously before React
mounts. Reads the URL, looks up a per-slug background colour from
localStorage (gallery-theme-bg-<slug>), and applies it to
documentElement immediately. Falls back to #171717 when no cache
exists and the OS prefers dark, so first visits with dark OS still
land on a dark background.
2. ThemeContext.applyTheme writes the resolved background to
localStorage keyed by slug whenever a gallery theme loads. Revisits
then hit the bootstrap cache and never see a flash.
Added a 200ms transition on html.background-color so the rare
cache→API drift (e.g. theme palette changed admin-side since last
visit) is a smooth fade instead of a snap.
Limitation: first visit on a light-OS device to a dark gallery still
flashes once. Killing that case requires a server-rendered theme hint,
out of scope for an SPA bootstrap fix.
The empty-skeleton-grid part of the same report is already addressed
by the 300ms lazy render in #352 — Rekoo-PS just needs to update from
v3.32.1-beta.0 to v3.32.2-beta.0+.
The "Expires on" preview under the days-after-event input rendered
nonsense dates (e.g. 25.04.2026 + 120 days → 08.01.2095, ~68 years
out). Cause: handleInputChange stores e.target.value verbatim, which is
a string for <input type="number">, so formData.expires_in_days is "120"
not 120. date-fns addDays does:
_date.setDate(_date.getDate() + amount)
When amount is a string, the + is string concatenation:
25 + "120" = "25120". setDate("25120") then sets day-of-month to 25120,
which carries over by ~68 years.
Fix: cast to Number at the call site. The validation/API-payload
codepaths already work because the comparisons at line 330 and the
JSON payload coerce numerically through different paths — only addDays
was actually broken.
The TypeScript type FormData.expires_in_days: number is a lie because
handleInputChange's [field]: e.target.value sets a string regardless.
Tightening that handler is a separate cleanup; this commit only fixes
the visible date bug.
Asymmetric JWT verification was causing a /admin/login → /admin/dashboard
→ /admin/login redirect loop for users carrying admin cookies issued
before the iss: 'picpeak-auth' claim was added (commit 23cd9cb,
"address Shannon security assessment findings (37 vulnerabilities)").
The frontend uses GET /auth/session as the source of truth for "is the
user authenticated?". That endpoint called jwt.verify(token, JWT_SECRET)
with no issuer option, so it accepted pre-issuer tokens and reported
valid: true. AdminLoginPage then redirected to /admin/dashboard, every
protected endpoint went through adminAuth which DOES verify the issuer,
each one rejected the token with 401, the response interceptor
window.location.href'd back to /admin/login, and the loop closed.
Fix: pass { issuer: 'picpeak-auth' } to /auth/session's jwt.verify so it
matches adminAuth and galleryAuth. Tokens without the claim now correctly
return valid: false from the session check, AdminLoginPage shows the
login form, and a fresh login mints a properly-issued cookie.
The other intentionally-lax verify call sites (logout-flow logging,
photoAuth, sessionTimeout, rateLimit) are unrelated to the loop and stay
lax — their callers don't gate "authenticated?" decisions on the result.
Reproducer: open a removed/archived gallery URL with a stale admin
cookie from before the issuer claim was added, click "Back to home" on
the gallery-not-found page → loop.
Two fixes for discussion #348.
Carousel-style swipe
The lightbox previously snapped to the next photo on swipe, then showed
a loading spinner while the new image fetched — choppy compared with
the reference video the reporter shared. The current photo is now
rendered inside a 3-slide track (prev/current/next). As the finger
drags, the track follows; on release the track animates to the
neighbouring slot or springs back if the gesture didn't pass the
threshold. Because the prev/next AuthenticatedImages render up front,
the browser starts fetching them while the user is still on the
current photo, so there's no loader flash on commit.
- Phase machine ('idle' | 'dragging' | 'committing' | 'springing')
drives the track's transform/transition. Commit + spring use a 280ms
cubic-bezier ease.
- Percentage-based transforms avoid measuring container width before
the first paint. Commit threshold (read from the ref on demand) is
max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least
40px of movement).
- transitionend advances currentIndex with wrap-around and resets the
track in one batch — slot contents rotate and the track snaps from
the commit position back to centered with transition: none, so the
visible image stays put. No flicker.
- Vertical-cancel (>24px dy) abandons the drag and springs back so the
user keeps the gesture they intended.
- touch-action: none on the carousel container stops the browser
fighting us with edge-swipe back navigation and native pinch-zoom.
- Pinch starting mid-drag springs the track back smoothly so the image
doesn't jerk under the second finger.
- onTouchCancel covers system-interrupted gestures (incoming call etc).
- dragX === 0 short-circuits to 'idle' instead of 'springing' so taps
don't get stuck waiting for a transitionend that never fires.
- Neighbour slides use a simplified AuthenticatedImage render (no
canvas/fragment-grid pipeline) since they're only on screen during
the swipe; the current slide keeps the full protection chain.
- Neighbour videos render their thumbnail rather than spinning up a
VideoPlayer. When the *current* photo is a video, the carousel is
bypassed entirely — single VideoPlayer + no swipe handlers — because
sliding a video element during a drag is awkward and adds nothing.
- Removed the now-redundant imageLoaded state + spinner;
AuthenticatedImage already shows a placeholder while loading.
Keyboard arrows and the on-screen Prev/Next buttons still snap (no
animation) — animating them would have required input queuing for
fast double-presses, and the request was specifically about swipe.
"Swipe to navigate" hint
Removed the mobile-only overlay text. Swipe is universal in image
viewers; the instruction read like training wheels and competed with
the photo for attention.
The gallery loading skeleton now renders the header bars immediately but
delays the 12-tile placeholder grid by 300ms. Galleries that load
quickly (the common case) never flash the empty grid before the real
photos render — addressing the follow-up reported on #321 — while
slower loads still get a placeholder so the page doesn't sit blank.
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.
Backend
- adminEvents.js: extend search to include customer_email so the column
shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
page can render an accurate "All (N)" / Total Events counter without
walking the full table on the client.
Frontend
- events.service.ts: getEvents() now accepts search + the full status
enum (active|inactive|archived|draft|expiring); response type matches
the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
filter, and 300ms-debounced search; Prev/Next + range/page indicator
below the table; placeholderData keeps the previous page visible
during fetches; stat cards and "All (N)" pull from /dashboard/stats so
totals stay accurate regardless of the visible page; archive/delete
invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
'expiring') directly instead of slicing the first 100 client-side. As
a side effect the dashboard's "expiring" definition now matches the
backend (was excluding events expiring within the next 24h).
The full documentation now lives at https://docs.picpeak.app — built
from the picpeak-docs Nextra repo. The README, SIMPLE_SETUP, and the
v1 OpenAPI generation flow all point there now.
Removed (now living at docs.picpeak.app):
- DEPLOYMENT_GUIDE.md (root-level — content covered by docs.picpeak.app/deployment)
- docs/ADMIN_SETUP_GUIDE.md
- docs/JWT_SECRET_MIGRATION.md
- docs/SECURITY_BEST_PRACTICES.md
- docs/admin-api-quickstart.md → docs.picpeak.app/api
- docs/nginx-fix.md → docs.picpeak.app/deployment/reverse-proxy
- docs/openapi.json, docs/openapi.yaml → still generated locally as a
build artifact (now gitignored), synced into picpeak-docs by
scripts/sync-api-docs.sh
- docs/picpeak-admin-api.openapi.yaml → ditto
Kept:
- docs/*.png (logo + screenshots — README still img-tags these)
Updated:
- README.md — replaced six in-repo doc links with docs.picpeak.app
pointers, restructured the Documentation section as a curated link
list to the new site
- SIMPLE_SETUP.md — single deployment-guide link redirected
- .gitignore — docs/openapi.{json,yaml} are now build artifacts, not
tracked
- backend/src/routes/v1/events.js — comment clarifies the OpenAPI flow
The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.
Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:
{ id, slug, event_name, event_type, event_date,
share_url, share_token,
customer_name, customer_email, customer_phone }
Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.
Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)
PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
null contract, and a Callout warning.
Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
The Settings page packed 13 tab buttons into a single horizontal nav
that overflowed even at 1440px — items wrapped or got clipped, and
"Webhooks" disappeared off the right edge entirely. Pattern was the
right call at 5 tabs and broken at 13.
Replaces the flat row with the macOS Settings / Stripe / GitHub pattern:
- **Desktop (lg+)**: 220px sticky left rail with five labelled groups —
General, Display, Privacy & Security, Integrations, System — and a
lucide icon next to every item. Active state uses the existing primary
token. Adds a section header on the right pane that echoes the active
item so the context is obvious after a switch.
- **Mobile (< lg)**: native <select> with <optgroup> per category. One
tap to switch, no horizontal scroll, screen-reader friendly.
Categories chosen to be balanced (avg 2.6 items/group) and to map to
how admins actually think about these settings rather than alphabetical
or insertion order. Ports the existing inline-fallback i18n pattern for
the new group labels.
When feedback was enabled the lightbox bottom toolbar packed counter +
zoom + download + like + 5-star + comments into a single row that
overflowed the viewport on iPhone-class widths, putting the rating
stars under the screen edge and below the iOS home indicator.
Changes:
- Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile,
so all controls fit (375px viewport: max-right 363 < 375; 390px:
max-right 378 < 390; 393px: max-right 393 < 393).
- pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row
sits above the iOS home indicator on devices with a gesture bar.
- Close button top/right now use max(1rem, env(safe-area-inset-*)) so
it doesn't disappear under the notch / dynamic island.
- "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it
clears the now-taller wrapped toolbar.
- index.html viewport meta gains viewport-fit=cover to enable
env(safe-area-inset-*) on iOS Safari.
Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14
(390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape
(852x393) — toolbar fits, photo centered, no clipping.
Found via real-browser verification: with useState the prior commit's
handleTouchEnd captures swipeStart from its render closure, so when
touchstart and touchend fire inside the same React batch (fast swipe,
synthetic events, or a tight render cycle) the end handler reads the
stale null and skips navigation. useRef sidesteps the closure entirely
and is the right primitive for cross-event scratchpad state anyway.
Verified in a 4-photo gallery on mobile-emulation (390x844 touch):
- left swipe (-200px) advances 1/4 → 2/4
- right swipe (+200px) returns 2/4 → 1/4
- 20px swipe (under threshold) does not navigate
- vertical swipe (dy 300, dx 20) does not navigate
WhatsApp / Slack / Facebook / Twitter previews showed nothing useful for
shared gallery links — the SPA's stub index.html has no OG tags and the
meta-injection in DynamicFavicon happens at runtime, which crawlers
never see (they don't execute JS).
Add a backend OG handler at /og/gallery/:slug that returns minimal HTML
with proper og:* and twitter:* meta sourced from the event row + branding
settings (event name, formatted date, welcome_message excerpt as
description, configured logo as the preview image, FRONTEND_URL-based
canonical). Honours slug redirects so renamed galleries still get rich
previews.
Wire crawler detection in both nginx configs (production and dev) — UA
match against the standard list (facebookexternalhit, WhatsApp, Slackbot,
Twitterbot, Discordbot, LinkedInBot, etc.) triggers an internal
rewrite to /og/gallery/:slug, while humans fall through to the SPA via
try_files. The OG endpoint is also wired into the native-install SPA
fallback in server.js for setups that bypass nginx.
The OG image is intentionally the brand logo, not a gallery photo —
crawlers fetch it without auth, and password-protected gallery photos
must not leak via share previews.
The lightbox showed a "Swipe to navigate" hint on mobile, but the touch
handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe
fell through and the user could only navigate with the on-screen arrows.
Add a 1-finger swipe detector: track the initial touch position, and on
touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious /
goToNext when the horizontal swipe exceeds 50px, dominates over
vertical motion (1.2x), and completes within 600ms. Suppressed while
zoomed in so the user can pan the image instead.
The phone field added in #322 was wired into the edit form but never
rendered in the read-only event-info panel, so admins could only see the
number while editing. Add a phone row gated on event_phone_field_enabled
(same toggle the form uses), and tighten the Event type so customer_phone
is no longer accessed via `(event as any)`.
Three fixes uncovered while bringing the backup-s3 integration suite to
12/12 against MinIO + Postgres:
- backupService.getDatabaseBackupInfo: pg's jsonb driver auto-parses
`statistics` / `table_checksums` to objects; the old JSON.parse() then
threw "[object Object]" is not valid JSON and the manifest dropped
database info silently. Accept both string and object inputs.
- backupService.runBackup: incremental path called
backupManifest.loadManifest() with an s3:// URI directly, which falls
through to fs.readFile() and ENOENTs — every "incremental" backup
silently downgraded to a full one. Added loadManifestFromAnywhere()
helper that downloads s3:// to a tmp file before delegating.
- backupManifest.generateIncrementalManifest: attached the `incremental`
section AFTER generateManifest() had already stamped
verification.total_checksum, so every incremental manifest failed
validateManifest() on read-back. Recompute the checksum after.
Test side: updated assertions to the current manifest shape
(`incremental.changes.modified_files_count`), Number()-coerce bigint
columns from pg, and gate the logger mock on UNMOCK_LOGGER for
diagnosing similar silent-failure modes in the future.
Three pre-existing bugs surfaced by re-running the backup-s3 integration
suite. backup-s3 went 0/12 → 7/12 (storage-refactor session bootstrap
fixes) → 10/12 with this commit.
1. Backup service crashes on backend startup with
`TypeError: Cannot read properties of undefined (reading 'replace')`
from node-cron's expression parser.
Root cause: `backup_schedule` stores a UI label like "weekly", while
`backup_schedule_cron` stores the actual cron expression. Startup
code read the label and passed it straight to cron.schedule() —
"weekly" is not a cron expression.
Fix in startBackupService(): read backup_schedule_cron first; fall
back to mapping known labels (hourly/daily/weekly/monthly) to cron
expressions; back-compat for deployments that wrote a cron expression
into the legacy backup_schedule field.
2. Backup manifest retrieval fails with
`SyntaxError: Unexpected token 'a', "applicatio"...` when the
manifest format is YAML.
Root cause: getBackupManifest() downloads the s3:// manifest to a
tmp file hardcoded as `manifest-N.json`. loadManifest() then
detects format from extension only — sees .json, runs JSON.parse on
YAML content (which starts with "application: …"), fails.
Fix in backupManifest.loadManifest(): detect format from BOTH the
extension AND the content's first non-whitespace character. JSON
starts with { or [; anything else falls through to yaml.load.
Backwards compatible — extension is still authoritative when present
AND content matches.
3. Test assertion `expect(backupRun.total_size_bytes).toBeGreaterThan(0)`
fails with "received value must be a number or bigint" because pg
driver returns bigint columns as strings. Coerce via Number() in
the test.
Remaining 2 failures (out of scope here, both are spec-level drift):
- "should include database backup" expects the runBackup() flow to
upload the database backup file at S3 key `database/db-backup.sql`.
Current implementation reads db backup metadata for the manifest but
does not upload the file itself. Missing feature, not a test bug.
- "should only upload changed files" expects manifest.incremental.
modified_files_count. Implementation writes backupType: 'incremental'
on the run row but no per-run incremental subobject in the manifest.
Field shape mismatch.
Closes the user-facing surface for the two #328 follow-ups previously
landed in code form (presigned route + S3 mode notes), plus the schema
migration that backs both #328 and #327 follow-ups.
Migration 083
- events.allow_presigned_download — per-event opt-in for the
presigned-URL "Download All" path. Off by default because it bypasses
watermarks; admins flip it knowingly. Mutually exclusive with
watermark_downloads.
- webhooks.filter (jsonb default {}) — dot-path equality predicate
evaluated at fire time. Empty object = no filter, fire always.
Backs the filter logic that shipped with #327.
- webhooks.template (text nullable) — optional ${dot.path} string
substitution applied at delivery time. NULL = use the default JSON
envelope (back-compat). Backs the template logic from #327.
S3 prefix walker (services/s3AutoImporter.js)
- Replaces the chokidar file-watcher in S3 mode (where there's no
inotify equivalent on remote objects).
- Polls every active event's S3 prefix every 5 min by default
(STORAGE_AUTO_IMPORT_INTERVAL_MS overridable).
- Eventual-consistency gate: an object is only imported after it's
been seen for two consecutive polls. Avoids flapping when S3 returns
a freshly-uploaded object that disappears on the next list (a
documented S3 behavior on certain backends).
- Skips generated artifacts (thumb_*, hero_*, dot-files).
- Inserts photos rows + fires photo.uploaded webhooks the same way
the local fileWatcher does.
- Opt-in via STORAGE_AUTO_IMPORT=true. Off by default because it adds
API call cost.
EventDetailsPage UI (frontend)
- Round D queryKey alignment for #325 dedup — replaces useQuery on
publicSettingsService with the shared usePublicSettings() hook so
the page joins the same React Query cache as every other consumer.
- Per-event "Allow direct S3 download (no watermark, S3 mode only)"
toggle in Download Protection. Disabled when watermark_downloads is
on; tooltip explains the bandwidth/watermark trade-off. Toggling
watermark_downloads on automatically clears allow_presigned_download
to keep the two mutually exclusive in the UI.
Verified live against MinIO
- Presigned: GET /api/gallery/.../download-all → 302 with
Location: http://minio:9000/...?X-Amz-Signature=...&X-Amz-Expires=300.
Following the URL inside the docker network → HTTP 200, valid
PK ZIP archive containing the photo.
- Auto-importer: dropped a file via `mc cp` directly into the bucket;
watcher imported it after 2 polls; webhook subscribed to
photo.uploaded fired with source=s3-auto-import; receiver got POST
with valid HMAC, status=success, 3ms latency.
PicPeak POSTs lifecycle notifications to admin-configured URLs. Each
delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header.
Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration
tests, full UI click-through via Chrome DevTools.
Schema (migration 082)
- webhooks: id, name, url, secret (plaintext — required to compute HMAC
for every outbound POST), secret_preview, events[], active, filter,
template, created_by, timestamps, last_success_at/last_failure_at.
- webhook_deliveries: webhook_id (FK CASCADE), event_type, payload,
attempt_count, status (pending|success|failed), response_status,
response_body (truncated to 1KB), latency_ms, next_retry_at,
last_error, created_at, completed_at. Composite index
(status, next_retry_at) serves the worker's hot-path query.
Service + worker
- webhookService.fire(eventType, data) — non-throwing entry point used
by lifecycle hooks. Looks up active webhooks subscribed to the event
and applies their per-webhook filter (dot-path equality predicate)
before enqueueing one webhook_deliveries row per match. Filter and
template logic ship in this commit; admin surfaces in the follow-up.
- webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5
pending rows; per delivery: re-validates URL via networkValidation
(DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS),
signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome.
Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response
body truncated to 1KB before storage. If a webhook has a template,
the rendered string replaces the JSON envelope as the request body
(signature is computed over the bytes actually sent).
Lifecycle wiring
- adminEvents.js POST /events → event.created (+ event.published when
not draft); POST /:id/publish → event.published.
- routes/events.js (legacy public POST) → event.created + event.published.
- routes/v1/events.js (#322 API) → event.created + event.published on
create, photo.uploaded on photo POST.
- archiveService.archiveEvent() → event.archived. Per-photo
photo.deleted intentionally NOT fired during cascade — receivers
infer from event.archived to avoid flooding (issue spec).
- expirationChecker.handleExpiredEvent() → event.expired BEFORE the
cascading archive (so receivers see expired→archived in order).
- adminPhotos.js — photo.uploaded on each batch row, photo.deleted on
single + bulk delete.
- photoProcessor.js — photo.uploaded for guest uploads + auto-import
(covers all entry paths).
- fileWatcher.js — photo.uploaded on add, photo.deleted on unlink
(local mode only).
Admin endpoints (mirrors adminApiTokens.js pattern)
- /api/admin/webhooks: GET list, POST create (returns plaintext secret
exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic
fire), GET :id/deliveries (paginated, filter by status), GET
:id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay.
Frontend
- Settings → Webhooks tab (mirrors API Tokens layout): name + URL +
event checkboxes + "Advanced" expander for filter (JSON) and template.
Plaintext secret shown once on creation with a Copy button. Active/
Disabled toggle button per row.
- /admin/webhooks/:id/deliveries — operational debug surface. Table
with timestamp/event/status/attempts/HTTP/latency. Status filter chips
(all/pending/success/failed). Row click → slide-over with payload +
signature + response body. Replay button on failed rows. Send-test-event
dialog. Auto-refresh every 10s.
Dev infrastructure
- dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that
records every POST to an in-memory ring buffer. Exposes GET /requests
for the E2E spec to assert deliveries landed with the right HMAC.
Sibling pattern to MinIO. Reachable from the backend at
http://webhook-receiver:8888 inside the picpeak network.
Tests
- backend/__tests__/integration/webhookDelivery.test.js (8/8) —
signature verification, headers, retry/backoff, max-attempts → failed,
response truncation, disabled-mid-flight, SSRF block, start/stop
idempotency.
- tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger
event.published → assert receiver got POST with valid HMAC → visit
deliveries page → row visible with status=success → API test event →
API replay → disable webhook → assert no new delivery.
Docs
- README §"Webhooks" — event catalog, payload shape, HMAC verification
in Node + Python + bash, retry semantics, SSRF protection.
- .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS,
WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS,
WEBHOOK_MAX_ATTEMPTS.
Out of scope for v1 (per issue): webhook templates' code-eval (the
${dot.path} substitution that ships is pure string replacement, no
expression engine — see follow-up commit), per-webhook rate limiting
beyond the global concurrency cap, synchronous "ask before delete"
webhooks.
Spanning files
- App.tsx pulls in this commit with both the AnalyticsBootstrap
(#325 dedup) and the WebhookDeliveriesPage route registration.
Splitting via git add -p was forfeit for sanity; the single 92-line
diff is honest about both contributions.
- adminEvents.js diff bundles the webhook fires AND the
allow_presigned_download field plumbing (#328 follow-up). Same
reasoning.
- The new webhookService/Worker/adminWebhooks files include the filter
and template logic from the follow-up — they were authored in one
pass; splitting them post-hoc would have produced fragile partial
files. The follow-up commit covers the migration and the UI for these.
Lets PicPeak write photos, thumbnails, hero images, watermarks, and
archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2,
Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local
filesystem. Selected via STORAGE_BACKEND=local|s3.
Architecture
- backend/src/services/storage/StorageBackend.js — abstract interface
(put/get/exists/stat/delete/list/copy/rename/signedUrl/putFromFile/
getToFile) — typedef-only, documents the contract.
- LocalFsStorage.js — wraps fs with atomic-write-via-tmp-rename, path
traversal protection, list-as-walker.
- S3StorageBackend.js — thin wrapper around the existing
S3StorageAdapter (used by backupService) mapping it onto the canonical
interface; supports optional STORAGE_S3_PREFIX namespace.
- index.js — factory selected by STORAGE_BACKEND with startup ping
(HEADs sentinel key on S3, fs.stat on local) so misconfig fails fast
before the first request.
Consumer refactors (~12 services + routes), each parametrized over the
abstraction:
- imageProcessor / videoProcessor — pipe Sharp/ffmpeg output through
storage.put; expose withLocalCopy() helper for S3-mode regeneration
paths that need a local file for sharp/ffmpeg.
- archiveService / downloadZipService — finalize zip in tmp dir, then
storage.putFromFile. Atomic-rename pattern preserved on local; S3
emulates via copy + delete (worker prunes orphaned .tmp.* on startup).
- photoProcessor / photoReplacementService / adminPhotos upload+delete /
routes/v1/events.js POST /events/:id/photos / routes/events.js — every
upload path now goes storage.putFromFile(temp) → unlink temp.
- gallery.js bulk-download (cached + on-the-fly + selected) — managed
photos via storage.get, external-mode unchanged.
- protectedImages / secureImages / photoResolver — read via
storage.get; resolvePhotoStorageKey returns the canonical key.
- watermarkService / watermarkGeneratorService — persistent watermarks
via storage.put.
- fileWatcher — bails out with a clear log warning when STORAGE_BACKEND=s3
(chokidar can't watch S3); auto-import lands via the S3 prefix walker
introduced in the follow-up commit.
- expirationChecker — small touch (event.expired webhook fire from #327
shipping in the next commit).
Migration tooling
- backend/scripts/migrate-storage.js — one-shot --dry-run capable script
that walks photos.path, thumbnail_path, hero_path, watermark_path and
events.archive_path/download_zip_path; streams local → S3; sha256
size-match skip for idempotent re-run; failures CSV.
Presigned-URL "Download All" (#328 follow-up shipped in this commit)
- routes/gallery.js — when STORAGE_BACKEND=s3 + event.allow_presigned_download
+ downloads enabled + watermark NOT enabled, /download-all returns a
302 redirect to a 5-minute presigned S3 URL. Per-event opt-in surface
ships in the next commit's UI.
Tests
- backend/__tests__/integration/storageBackend.test.js — parametrized
contract suite running against BOTH LocalFs AND MinIO (18 tests, both
backends — 36 cases total).
- backend/__tests__/integration/imageProcessor.storage.test.js — same
parametrized pattern for the image processor (10 tests × 2 backends).
- backend/__tests__/integration/backup-s3.test.js — bootstrap fix:
drop the redundant initDb() (001_init handles it) and remove
schema-drift in configureS3Backup (app_settings has no created_at
anymore and the unique constraint is on setting_key alone, not
composite). 0/12 → 7/12 (5 remaining are unrelated assertion drift).
- backend/src/services/photoResolver.js — mixed-source events (reference
mode with managed-uploaded photos) now fall back to managed when
external_relpath is missing instead of throwing.
- tests/e2e/s3-storage-roundtrip.spec.ts — Playwright spec that
auto-skips against local backend; full upload → serve → delete
round-trip when run against an S3-mode backend.
Server wiring (server.js)
- initStorage() called after database init, before rate limiters.
- This commit's diff also includes the webhook delivery worker startup
and the S3 auto-importer startup. Those features ship in the next two
commits — co-located here for one bisectable diff per file.
Docs + ops
- README §"Storage Backends" — capability matrix, switching playbook,
IAM policy snippet, MinIO/R2/B2 examples.
- README §"Webhooks" — also added here (full diff bundled).
- .env.example — STORAGE_BACKEND + STORAGE_S3_* + STORAGE_AUTO_IMPORT
documented; WEBHOOK_* added in the same diff.
- .gitignore — re-anchor the existing `storage/` rule to `/storage/`
so backend/src/services/storage/ (the new abstraction code) is
trackable. The runtime ./storage/ data dir stays ignored.
Out of scope for v1 (per the issue): presigned URLs for individual
photo display (always streamed for protection middleware), CDN
integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket
per-event.
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.
Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.
Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
60s staleTime, queryKey ['public-settings']. Vitest with mocked api
proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.
Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
refetchInterval to preserve maintenance polling), MaintenanceWrapper
(drops the now-redundant per-route ping; axios interceptor already
handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
CreateEventPage. EventDetailsPage Round D ships in the follow-up
commit that adds presigned-download UI on the same page.
App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
Adds a yellow Buy Me a Coffee badge to the header alongside the existing
License/Docker/Node/React badges, plus a small "Support the Project"
section above Acknowledgments with the standard BMC button image. Also
adds a link in the inline nav row at the top of the README so first-time
visitors can find it.
Link: https://buymeacoffee.com/theluap
Lightweight, opt-in support — explicitly notes that starring, sharing,
filing good bug reports, and opening PRs are equally welcome ways to
help if money isn't in the budget.
Visiting /admin/dashboard while logged out caused a navigation storm:
the dashboard fires ~7 /api/admin/* queries on mount, each returns 401,
each axios interceptor call did `window.location.href = '/admin/login'`.
The path-based guard `currentPath.includes('/admin/login')` reads
`location.pathname` *synchronously* — but `location.href = …` is async,
so all 7 parallel handlers saw the still-old pathname and each fired a
fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user
saw a flicker storm. Same shape would bite any admin page that fans out
queries on mount.
Add a module-level `adminLoginRedirectPending` flag set the moment we
kick off the first redirect; subsequent 401s in the same tick see it
and skip. Single navigation, clean transition to login.
Smoke spec 10-admin-redirect-loop locks the regression in by sampling
the URL across 5 ticks — if any tick lands somewhere other than
/admin/login, the spec fails.
Every <button> inside ThemeCustomizerEnhanced was bare — no `type`
attribute, defaulting to `type="submit"`. Inside CreateEventPage's
<form onSubmit={handleSubmit}>, that turned every theme/layout/header/
divider/control/colour-mode/CSS-template click into a form submission.
When the form was empty, validation killed the submit silently — that
showed up earlier as #317.2 ("theme picker unclickable").
When the form was filled (event_name set, etc.), validation passed,
`createMutation.mutate(payload)` ran, and the user was navigated to a
freshly-created event they never asked for — #326's reported symptom.
Fix: add `type="button"` to all 9 unmarked <button>s in the customizer.
Also covered by smoke spec 09-create-event-no-instant-submit which fills
the form, clicks Modern Masonry, and asserts the URL stays on
/admin/events/new and the events count is unchanged.
Adds a long-lived bearer-token mechanism + scoped REST surface designed
for n8n-style automation: create a gallery, upload photos, fetch the
share URL — all via documented HTTPS endpoints instead of poking at the
admin UI's internal routes.
API
- Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK,
last_used/expires/revoked timestamps).
- New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`,
resolves to the owner admin user, attaches `req.admin` so existing
permission decorators (events.create etc.) still work. Token-level
scope check (read/write/admin) layers on top as defence in depth —
a leaked read-only token cannot mutate even if its owner is super_admin.
- adminApiTokens route exposes list/create/revoke for admins (cookie-
authed). Plaintext token is returned exactly once on creation.
- v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id,
POST /events/:id/photos (multipart, single file), GET
/events/:id/share-link. Each endpoint annotated with @openapi JSDoc.
Documentation
- swagger-jsdoc + swagger-ui-express produce a live spec at
/api/openapi.json and a Swagger UI at /api/docs (admin-gated).
- backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml}
to the repo so the spec is versioned.
- scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and
copies it into the picpeak-docs Nextra site at app/api/. Writes only,
never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass).
Frontend
- New Settings → API Tokens tab: generate, list, revoke. Plaintext
tokens are shown once with a copy-to-clipboard control.
Adds a `customer_phone` column on events plus an `event_phone_field_enabled`
admin setting (default off) that surfaces the input in the create-event
and event-detail forms. Designed for downstream automation tooling — once
exposed via the upcoming public API, n8n / similar can pick it up to
deliver gallery links over WhatsApp, SMS, etc.
- Migration 080 adds the column + seeds the setting as false. Existing
deployments see no UI change unless the admin opts in via
Settings → Events.
- Backend strips the field server-side when the toggle is off (defence
in depth against form bypass).
- Frontend renders the input only when the public-settings flag is true;
always optional even then.
- publicSettings + EventSettings types extended; CreateEventPage and
EventDetailsPage wired to read the toggle and submit the value.
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.
Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
migration on existing deployments. Null falls back to the global
branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.
Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
the standard branded shell (logo precedence: page → branding → bundled
default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
identifier + infoError archived/missing) into a single
CMSContentBlock("gallery-not-found"), so admins can edit one source
of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
control per page; falls back to the page's own English title in the
page list when no `legal.<slug>` translation is registered.
The "which preset does this saved theme match?" loop in BrandingPage and
CreateEventPage was doing a full JSON.stringify equality on preset.config
vs the loaded theme. The previous #323 logo-preservation work means the
saved theme legitimately carries a `logoUrl` (and any other fields the
parent maintains), so the equality check would never match and the
preset summary fell back to "Custom Theme" / Classic Grid even when the
saved theme was structurally Dark Modern, etc.
Compare only on the preset's own keys instead. Surfaced by the new
smoke spec 07-branding-default-on-create-event which would otherwise
pass green against the broken state.
JWT `iat` has 1-second resolution; `password_changed_at` is stored with
sub-second precision. The previous comparison rejected tokens whose iat
fell in the same wall-clock second as a password change — e.g. a token
issued by an immediate re-login after a password reset, or by any
script-driven flow that resets and logs in in quick succession. Floor
the stored timestamp to whole seconds before comparing.
Caught while wiring up the local E2E suite: the seeder needed a
"set password_changed_at 10 s in the past" hack to avoid this race;
with the fix in place that hack is gone and the suite is naturally
deterministic.
Adds `pid` and `uptime` fields to the /health response so external monitors
(and the local E2E watchdog) can detect a silent process restart between
two checks — e.g. an unhandled rejection that crashes Node and Docker
quietly relaunches the container.
Also adds .gitignore patterns for a local-only E2E suite that lives in
tests/e2e/local/ on individual machines and is never pushed.
#323-A — Branding colour changes weren't persisting unless "Apply changes
immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was
gating its `onChange` callback on `isPreviewMode`, but the parent
BrandingPage already gates global `setTheme()` on its own copy of that
flag — so the customizer's gate was double-gating and silently dropped
the new values from the parent state that Save reads from. Always
propagate `onChange`; let parents decide what's "live". Removed the now
no-op `isPreviewMode` prop and dropped the unused passers.
#323-B — Default theme set in Branding wasn't applied to new events.
CreateEventPage only inherited the event-type's recommended preset, with
'default' falling back to Classic Grid. Now reads `settings.theme_config`
on first load and uses it as the form's starting theme; the event-type
effect skips the generic 'default' so the Branding default sticks for
event types like "Other".
#321 — Visitors saw four sequential render states when opening a gallery
(full-page "Loading Gallery" → "publicly accessible — loading photos"
card → skeleton grid → real gallery). Extracted the skeleton into a
shared <GallerySkeleton/> and used it for both GalleryView's photos-
loading state and GalleryPage's gallery-info-loading + public-auto-login
phases. The "publicly accessible" interstitial is gone. Net: one
continuous skeleton from URL open until real photos render.
- Share link: display and copy now use the absolute URL built from the
current origin instead of the relative path stored in events.share_link.
Added a Copy Link button to the events list (inline + dropdown).
- Detect dev tools default: event creation now reads the global
enable_devtools_protection app setting instead of always falling back to
the column default; admins who disable it globally get new events with
it disabled too.
- Require password default: added a global "Require password by default"
setting (event_default_require_password, default true), exposed via
Settings -> Events. Create-event form initialises from it.
- Filter bar: added gallery_show_filter_bar setting and hide the search/
sort row in the public gallery when off, or when the gallery has zero
photos (fixes the empty-state UX from the screenshot).
- Theme picker unclickable on Create Event: memoised availableEventTypes
so its identity is stable. The "auto-apply event-type recommended
preset" effect was firing on every render due to the unstable array
reference and silently overwriting the user's preset selection ~1ms
after each click.
- Branding logo disappearing on theme change: handlePresetChange and
handleThemeChange no longer wipe the existing logoUrl when a preset
config (which carries no logoUrl) is applied; handleSave falls back to
brandingSettings.logo_url. themeMutation now invalidates the
admin-settings and public-settings caches so saved theme changes appear
immediately.
Archiving an event with no admin_email queued an email_queue row with
recipient_email=null, violating the NOT NULL constraint. The error was
thrown inside the output.on('close') callback (detached from the caller),
becoming an unhandled rejection that crashed Node and dropped admin
sessions on bulk archive.
- Skip queueEmail when event.admin_email is null/empty (admin_email has
been nullable since migration 073).
- Wrap the close handler in try/catch so any post-archive failure logs
instead of crashing the process.
Pre-zip downloads:
- Generate ZIP in background after photo mutations (upload/delete/watermark change)
- Serve cached zip with Content-Length for instant downloads and native progress bar
- Falls back to on-the-fly streaming when no cache exists yet
- Frontend uses browser-native download when zip is ready (no blob buffering)
- New downloadZipService with debounced regeneration and in-memory locking
Photo replacement:
- Admin upload form gets "Replace existing photos with same name" checkbox
- Matches by original_filename (case-insensitive) within the same event
- Preserves photo ID, position, feedback, category, and visibility
- Updates file, thumbnail, dimensions, EXIF capture date on replacement
- Ambiguous matches (multiple photos with same name) skip replacement with warning
- New photoReplacementService with findReplacementCandidate and replacePhoto
AdminPhotoGrid uses AdminAuthenticatedImage which fetches via Axios
(baseURL: /api), so the backend URL must not include /api — Axios
adds it. The adminGuests.js /api prefix is correct because its
consumer (AuthenticatedImage) uses fetch() with buildResourceUrl().
- Render welcome_message in gallery view for all non-fullpage layouts
(grid, masonry, carousel, timeline, mosaic) as a centered banner
- Add /api prefix to thumbnail/photo URLs in adminGuests.js and
adminPhotos.js so they route correctly through Nginx proxy
- Gallery now respects the configured sort direction (asc/desc) from
default_photo_sort setting instead of using hard-coded directions
- Photos endpoint zeroes out feedback fields (like_count, favorite_count,
average_rating, comment_count, has_feedback) when show_feedback_to_guests
is disabled, while still showing data to admin/client users
Adds a third value for the COOKIE_SECURE environment variable that
decides the cookie Secure flag per-request based on req.secure. This
unblocks a common self-hosted setup where the same PicPeak deployment
is reachable over both HTTPS (via reverse proxy) and plain HTTP (e.g.
LAN access at http://192.168.x.x:3001).
Behavior
unset - legacy default: follows NODE_ENV (production=true, dev=false)
true - always set Secure (unchanged)
false - never set Secure (unchanged)
auto - NEW: use req.secure per request. In practice this means
Secure on HTTPS requests (when X-Forwarded-Proto: https
reaches Express via a trusted proxy) and no Secure flag
on plain HTTP requests.
The existing trust proxy config (`app.set('trust proxy',
'loopback, linklocal, uniquelocal')` in server.js) means
X-Forwarded-Proto is honored when forwarded from local/private-network
proxies, which covers Docker network setups and most self-hosted
deployments behind NPM, Traefik, or Caddy.
auto is strictly opt-in. The default behavior is unchanged, so existing
users see no difference. A follow-up release can consider promoting
auto to the default after real-world feedback.
Also fixed (latent bug, benefits everyone)
Cookie clear operations (clearAdminAuthCookie, clearGalleryAuthCookies)
previously wrote the same `secure` attribute as the set path. When a
cookie was set with Secure=true over HTTPS and the clear request came
over HTTP (or vice versa under auto mode), some browsers would reject
the Set-Cookie delete header, leaving the cookie in place. Browsers
match cookies by (name, domain, path) for deletion and don't care about
Secure, so the new buildClearCookieOptions() helper simply omits the
secure attribute.
Implementation
- secureCookie string is replaced by secureCookieMode which can hold
true, false, or 'auto'.
- New resolveSecureFlag(res) returns the boolean for a specific
response, delegating to res.req.secure when in auto mode.
- buildCookieBaseOptions and buildCookieOptionsWithExpiry now take res
and pass it through.
- New buildClearCookieOptions() deliberately omits `secure`.
- setAdminAuthCookie / setGalleryAuthCookies / clearAdminAuthCookie /
clearGalleryAuthCookies all updated to thread res where needed.
Public signatures unchanged — every caller already has res in scope.
Testing
Verified against a real Express instance inside the backend container
with trust proxy configured, covering:
- (unset) + NODE_ENV=production -> secure: true (legacy)
- (unset) + NODE_ENV=development -> secure: false (legacy)
- COOKIE_SECURE=true + req.secure=false -> secure: true (literal wins)
- COOKIE_SECURE=false + req.secure=true -> secure: false (literal wins)
- COOKIE_SECURE=auto + X-Forwarded-Proto: https -> secure: true
- COOKIE_SECURE=auto + plain HTTP -> secure: false
- clearCookie always omits the secure attribute
Documentation
Added a COOKIE_SECURE block to both .env.example files (root for
docker-compose, backend/.env.example for native install) explaining the
four values, when to use auto, and the two requirements (proxy must
forward X-Forwarded-Proto, proxy IP must be in the trust list). Also
documented COOKIE_SAMESITE and COOKIE_DOMAIN alongside, which were
previously undocumented.
Fixes two bugs reported on #292 after 3.27.0-beta.0 shipped:
1. Masonry grid showed no visual feedback after liking a photo.
MasonryGalleryLayout's Like button had no liked-state plumbing —
the Heart icon was a static <Heart> regardless of whether the user
had liked the photo.
2. PhotoLightbox (fullscreen view) silently failed to like photos in
guest identity mode. submitLike() and submitRating() never called
ensureIdentity() before firing the API request, so the first
interaction from a fresh session hit a 401 from the server instead
of opening the name prompt.
Root causes:
1. MasonryGalleryLayout was missing the 'liked' state pattern that
GridGalleryLayout already uses (likedPhotoIds Set in the parent,
passed down as a `liked` prop, updated via onLikeSuccess callback).
The bug was invisible in simple mode (no personal state) but
surfaced immediately in guest mode where each guest expects to see
confirmation of their own action.
2. PhotoLightbox's submit handlers were written before the guest
identity context existed and only checked the legacy
require_name_email flag. They were never updated when guest mode
landed.
Also fixed: z-index conflict where the GuestNamePromptModal (z-50)
was sitting at the same level as PhotoLightbox (z-50), so when the
prompt opened over the lightbox, the fullscreen image intercepted
pointer events and the modal's Continue button was unclickable.
Bumped both guest modals to z-[60].
Changes:
- MasonryGalleryLayout.tsx
- MasonryPhotoProps gains `liked?: boolean` + `onLikeSuccess?: () => void`.
- Like button: red bg + filled white Heart icon when liked; aria-label
toggles between "Like photo"/"Unlike photo"; aria-pressed mirrors state.
- onClick wires onLikeSuccess() for optimistic UI in both guest-mode
and simple-mode branches plus the FeedbackIdentityModal onSubmit path.
- Parent layout holds `likedPhotoIds: Set<number>` and passes it to
each MasonryPhoto (matches the GridGalleryLayout pattern).
- PhotoLightbox.tsx
- Consumes useGuestIdentityOptional(); new `isGuestMode` flag.
- submitLike() and submitRating() get a guest-mode branch that calls
ensureIdentity() first and submits without body guest_name/email
(server reads from the verified token).
- Optimistic UI updates happen after successful submit in guest mode.
- GuestNamePromptModal.tsx, GuestRecoveryModal.tsx
- z-50 → z-[60] so they render above PhotoLightbox.
Verified end-to-end against local Docker with Playwright MCP on event
168 (Masonry Columns Test layout):
- Fresh session, click Like in Masonry grid → name prompt opens, register,
feedback persists with guest_id, Heart button turns red with
aria-pressed and "Unlike photo" label. Subsequent likes on other
photos also show red state. DB confirms feedback rows.
- Fresh session, open photo in lightbox BEFORE registering → click Like,
the name prompt correctly opens on top of the lightbox, register,
feedback persists. Rate 4 stars → works, average 4.0 (1) displayed
in lightbox, ★ badge appears on toggle-feedback button, grid cell
shows "1 likes" + "Rating: 4.0" indicators after closing lightbox.
- Backend DB: gallery_guests row created, photo_feedback rows have
correct guest_id, server reads name from verified token (body values
ignored).
Out of scope (documented in audit, not reported by the user, no
regression from guest mode): Mosaic/Carousel/Timeline have partial
optimistic-UI issues unrelated to this report; they pre-date guest
mode and behave the same in simple mode. Leaving alone per scope
discipline.
Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).
New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.
Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
req.guest.identifier takes precedence — per-person rate limits and
per-person deduplication.
Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
codes tables; identity_mode column + check constraint; nullable
guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
AdminGuestsList component.
Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.
Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
6-digit code via the existing emailProcessor, POST /guest/verify
exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.
Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
Timeline/Premium layouts.
Backwards compatibility
- Existing events default to 'simple' after migration; behavior
unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
guest_identifier) so per-guest counts are accurate without touching
legacy rows.
Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
displays thumbnail grid with badges; aggregate view sorts by picker
count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
Alice count = 4.
The Has Likes / Has Favorites / Has Comments checkboxes in the admin
Event > Photos tab updated local state but never affected the visible
photo grid, because the feedbackFilters state was only wired to the
export menu and the backend /admin/photos/:eventId/photos endpoint had
no support for these params.
Fixes:
- backend/src/routes/adminPhotos.js: extend GET /:eventId/photos to
accept has_likes, has_favorites, has_comments, min_rating, and logic
(AND/OR) query params and apply them via where-clause groups using
the existing denormalized like_count/favorite_count/comment_count/
average_rating columns.
- frontend/src/services/photos.service.ts: add hasLikes, hasFavorites,
hasComments, minRating, logic to the PhotoFilters interface and
append them as query params in getEventPhotos.
- frontend/src/pages/admin/EventDetailsPage.tsx: merge feedbackFilters
into combinedPhotoFilters (via useMemo) and key the admin-event-photos
query on it, so toggling any checkbox refetches with the new params.
Verified end-to-end against local Docker: seeded event with a known
feedback distribution and confirmed
- Has Likes → 4 photos
- Has Favorites → 3 photos
- Likes AND Favorites → 1 photo
- Likes OR Favorites → 6 photos
- Has Comments → 2 photos
- network requests carry the exact query params
The "Method 2: File System" section in SIMPLE_SETUP.md implied you
could create a gallery by just copying files to the storage directory.
In reality, the event must exist in the database first — the file
watcher only adds photos to existing events.
Rewritten to clarify the prerequisite and explain how the file watcher
works (2s stability delay, supported formats, auto-thumbnailing).
The redirect loop fix only covered MandatoryPasswordChangeModal.
The regular PasswordChangeModal (profile settings) had the same
issue — onSuccess updated React state but didn't handle the new
JWT cookie, causing the same redirect loop.
Also increase redirect delay from 500ms to 2000ms in both modals
so the success toast is visible before the page reloads.
The new token issued after password change had iat (integer seconds)
that was <= password_changed_at (millisecond precision), causing the
auth middleware's "iat < passwordChangedTime" check to reject it
immediately. Set iat explicitly to 1 second after password_changed_at.
E2E tested: login → mandatory password change → dashboard loads
successfully with no redirect loop and no 401 errors.
Add per-event default photo sort setting with 6 options:
- Upload Date (Newest/Oldest First)
- Date Taken (Newest/Oldest First) — uses EXIF captured_at
- Filename (A-Z / Z-A)
Backend:
- Migration 077 adds default_photo_sort column to events table
- Event create/update handlers accept and validate the setting
- Gallery info endpoint returns default_photo_sort for frontend
Frontend:
- "Date Taken" added to gallery sort dropdown (alongside Date, Name,
Size, Rating)
- Gallery initializes with event's default sort instead of hardcoded
"date"
- "Default Photo Sort" dropdown in event create and edit forms
- Photos without EXIF dates fall back to upload date
i18n: All 5 locales (EN, DE, NL, PT, RU) updated with sort labels.
Closes#283
#263: The mandatory password change modal updated React state before
the browser stored the new JWT cookie, causing a race condition where
the auth context checked the session with the old (invalidated) token.
Replace the state update with a full page redirect to /admin/dashboard
after a brief delay, ensuring the new cookie is applied cleanly.
#269: The file watcher service imported isVideoMimeType from
fileSecurityUtils where it doesn't exist. The function is exported
from videoProcessor. Fix the import path.
Closes#269
Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table
Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token
Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)
OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
branding settings
Editable Client Email:
- Customer email is now editable after event creation in edit mode
Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
from global branding configuration
Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper (plain-crypto-js) attributed to North Korean threat
actor UNC1069/Sapphire Sleet. The malicious versions have been removed
from npm but our ^1.12.2 range could have pulled 1.14.1 on next install.
Pin to exact version 1.14.0 (latest safe release) in both frontend and
backend package.json and lock files to prevent any future resolution to
compromised versions.
References:
- https://github.com/axios/axios/issues/10604
- https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/
After changing password, the backend sets password_changed_at which
invalidates the old JWT token. But the frontend still holds the old
token in the HttpOnly cookie, so the next session check returns 401,
triggering an infinite redirect loop between /admin/login and
/admin/dashboard.
Fix: issue a new JWT token cookie after successful password change
so the session remains valid without requiring re-login.
Beta themes (Gallery Premium, Gallery Story) display thumbnails at
400-800px, but the default thumbnail size is 300x300px, causing visible
pixelation. Show an amber warning banner with a link to Thumbnail
Settings when a beta layout is active and thumbnails are below 500px.
Warning appears both in the preset selector and the layout selector
sections of the theme customizer.
npm@latest resolves to v11 which has a broken promise-retry dependency
on Node 22 Alpine, causing Docker builds to fail. Pin to npm@10 which
stays compatible with the Node 22 base image.
Replace column-based email template languages (subject_en/subject_de) with
a normalized email_template_translations table where each language is a row.
This allows adding new languages without schema changes.
- Add migration 075 to create email_template_translations table, migrate
existing EN/DE data, and seed NL/PT/RU for customer-facing templates
- Update processTemplate() to query translations table with fallback chain
(requested lang -> en -> first available), with legacy column fallback
- Restructure admin email API to return/accept translations object format
- Update frontend EmailConfigPage with dynamic 5-language tabs, translation
count badges, and copy-from-language feature for empty translations
- Add Dutch to default language dropdown in general settings
- Add Dutch to clientAccessI18n and password security messages in emails
- Expand email domain detection for NL/BE/BR/PT/RU domains
- Add email i18n keys (copyFrom, noTranslation, etc.) across all 5 locales
Add complete Dutch translation (2054 keys) with Netherlands flag in the
language selector. Also synchronize all existing locales so every language
has the same set of keys: added 29 missing keys to EN/RU/PT and 95 missing
keys to DE (moderation, analytics, CSS templates, backup, events).
Use wrapEmailHtml() for the test email so it matches the look of all
other emails sent by the platform (logo, footer, etc.).
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Add a thumbnailScale field (xs/sm/md/lg/xl) to gallery layout settings
that adjusts column counts for Grid, Masonry (columns mode), and Mosaic
layouts. Each scale maps to a column offset applied on top of the
layout's base columns, letting photographers control photo density.
- Add thumbnailScale to GalleryLayoutSettings type
- Apply scale offset in Grid, Masonry, and Mosaic layout components
- Add thumbnail scale dropdown to admin theme customizer
- Conditionally show dropdown only for applicable layouts
- Safelist dynamic grid-cols classes in Tailwind config
- Add i18n keys for EN, DE, PT, RU locales
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
- Add Russian (Русский) to admin settings language dropdown
- Fix Premium layout hero using thumbnail instead of hero_url
- Hide "Uncategorized" section header in Story layout for uncategorized photos
- Add PhotoLightbox to Story layout so photo clicks open full-screen view
- Use full-res images in StoryPhotoCard instead of thumbnails
- Defer public gallery auto-login text until settings/locale are loaded
- Add validation and debug logging for email logo URL construction
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
- Set password_changed_at when changing password via adminAuth route so
existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
frontend updateUser service (fixes silent role update failures)
Resolves GHSA-rqg3-47p5-vgwg
- Fix dimension repair for external media by using photoResolver instead of hardcoded paths
- Extract photo dimensions via Sharp during external media import
- Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition
- Pass event welcome_message to Story layout footer for per-event customization
- Add email_primary_color/email_secondary_color settings with admin UI color pickers
- Add i18n keys for email branding in all 4 locales (en, de, ru, pt)
- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
- Upgrade multer to 2.1.1 (CVE-2026-3520, DoS via malformed requests)
- Update tar override to >=7.5.11 (CVE-2026-31802, CVE-2026-29786)
- Upgrade Node base image from 20-alpine to 22-alpine to fix npm
bundled tar/minimatch CVEs in the Docker image
The email template preview modal was showing only raw body HTML without
the styled wrapper (green header bar, logo, footer with company name)
that processTemplate() applies when sending. This made preview not match
what recipients actually receive.
Extract wrapEmailHtml() from processTemplate() and reuse it in the
preview endpoint. Also fix logo URL to use FRONTEND_URL consistently.
Closes#229
Replace raw HTML textarea with TipTap-based rich text editor for email
templates. Includes formatting toolbar, variable insertion dropdown,
source/visual toggle, and dark mode support. Add Mailhog service to
docker-compose for local email testing.
- Fix admin video upload missing media_type/mime_type and video processing (#203)
- Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220)
- Add photo dimension repair endpoint and admin UI (#180)
- Add E2E tests for all three fixes
When admin/customer emails were configured as optional in Settings >
Event Creation, the backend still rejected empty values because:
1. express-validator .optional() only skips undefined, not empty strings
— changed to .optional({ values: 'falsy' }) so "" is treated as
absent
2. DB columns host_email and admin_email had NOT NULL constraints
— added migration to make them nullable
3. Email queue insert crashed on null recipient_email
— skip queuing when no customer email is provided
Users behind Cloudflare Tunnel and other reverse proxies cannot upload
batches >100MB. The upload chunking previously used a hardcoded 500MB
limit. This adds a configurable `max_upload_batch_size_mb` setting
(default 95MB) to the admin General settings, leaving headroom below
Cloudflare's 100MB limit.
Add a new "Thumbnails" tab in the admin settings page allowing users to
configure thumbnail dimensions, quality, format, and fit mode from the UI.
Also fix backend route column name mismatch (key/value → setting_key/setting_value)
that caused a 500 error, and add a button to regenerate all thumbnails.
The "Allowed File Types" admin setting was stored in the database but
never actually read during upload validation. Both frontend and backend
used hardcoded MIME type lists, causing video uploads (e.g. MP4) to be
rejected even when explicitly added to the setting.
Changes:
- Add getAllowedMimeTypes() to uploadSettings service that reads the
general_allowed_file_types DB setting and converts extensions to MIME types
- Backend admin upload route now resolves allowed types from settings
before multer processes files (via resolveAllowedTypes middleware)
- Backend gallery upload route uses dynamic allowed types from settings
- Expose allowed_file_types in public settings API for gallery clients
- Frontend PhotoUpload and UserPhotoUpload components now derive allowed
MIME types from settings instead of hardcoded image-only lists
- Add shared fileTypes.ts utility for extension-to-MIME conversion
Closes#203
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
- Replace deprecated docker-compose (v1) with docker compose (v2) in README
- Add missing ADMIN_PASSWORD to .env.example so new users don't get a
blank-string warning and can actually log in after first setup
When expires_at is null (no expiration), the status logic defaulted
days to 0, causing all non-expiring events to display as "Expired".
Now returns "Active" immediately when there is no expiration date.
Surface the existing original_filename from the database in the admin
photo grid hover overlay and photo viewer sidebar, so photographers can
correlate uploaded images with their Lightroom/disk originals. Only shown
when it differs from the system-generated filename. Gallery guests remain
unaffected.
- Disable production source maps and hide nginx version
- Reduce JSON body limit from 10gb to 50mb (uploads use multer, not JSON)
- Strip database info and error details from health endpoint
- Mask reCAPTCHA secret key in admin settings API responses
- Whitelist sort/order query parameters in events and photos endpoints
- Stop reflecting arbitrary origins in static file CORS headers
- Align nginx security headers with backend Helmet CSP, remove deprecated X-XSS-Protection
- Strip EXIF metadata from generated thumbnails and hero images
- Bind postgres/redis dev ports to localhost in docker-compose configs
- Add safeExec utility (spawn with shell:false) to prevent command injection
- Convert all exec/execAsync calls in backup, restore, and database backup
services to use safe spawn-based helpers
- Add Update Instructions Dialog with environment-specific commands (Docker/Git/Standalone)
- Add email notification settings for new version alerts
- Add "Sort by Capture Date" option using EXIF metadata extraction
- Fix E2E tests by loading environment variables via dotenv
- Add test-images/ and backend/*.db to .gitignore
Closes#181
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please email security@example.com with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**, with an optional CRM / accounting suite. This image is the **all-in-one** build: the backend, the built web UI and SQLite in **one container, one process** — no compose file, no separate database, no reverse proxy to wire up.
-`x.y.z` — a pinned release (**recommended for production**)
-`beta` / `main` — latest build from `main` (may be unstable)
- **Architectures:** `linux/amd64`, `linux/arm64` (x86 and ARM NAS)
## Quick start
docker run -d --name picpeak -p 3000:3000 \
-v picpeak:/data \
-e JWT_SECRET="$(openssl rand -base64 48)" \
picpeak/aio:stable
Then open **http://localhost:3000/admin** and complete the setup wizard. Read the one-time setup token with:
docker exec picpeak cat /data/db/SETUP_TOKEN
> 🔗 Share links need to know your address. The image defaults `FRONTEND_URL` to `http://localhost:3000`; pass `-e FRONTEND_URL=https://photos.example.com` (or set the site URL in Settings) before you send a gallery to a client.
## Ports & volumes
- Container port **3000** (HTTP; put your own TLS terminator in front for public use).
- **One volume: `/data`** — back it up and you have backed up the install.
- **SQLite takes one writer at a time** — right for a home server, a NAS or a single studio; the compose stack with PostgreSQL is what scales.
- **No Redis** — background jobs run in-process.
- **Face recognition is unavailable** here. It needs the separate [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) sidecar, and a second image-processing pipeline competing with thumbnailing for one container's CPU would just make the install slow. Run the multi-container deployment for that feature.
You can move to the full stack later without reinstalling: take a `.picpeak` backup and restore it there.
## Docs
Volume layout, the external-Postgres variant, TLS, updates and the limits: **https://docs.picpeak.app/deployment/single-container**
**picpeak** is an open-source, self-hosted **photo-sharing platform for photographers**. This image is the **optional face-detection sidecar**: it detects faces in one image and returns a bounding box, five landmarks, quality signals and a 512-d embedding per face.
**Nothing else.** No database, no volumes, no state, no egress, no model download at runtime. Clustering, person identity, thresholds and every privacy decision live in the picpeak backend, where the data already is — this service forgets each image the moment it answers.
If you don't run this container, the feature does not exist.
-`x.y.z` — a pinned release (**recommended for production** — keep it on the **same** tag as the backend)
-`beta` / `main` — latest build from `main` (may be unstable)
- **Architectures:** `linux/amd64`, `linux/arm64`
> The sidecar's API contract is versioned with the backend that calls it, so `PICPEAK_CHANNEL` resolves the same string across all picpeak images.
## Turning it on
The maintained compose file already contains this service behind a profile — you do not write it by hand:
docker compose --profile faces up -d
Then two deliberate actions in the app, neither of which is installing this container:
1. Enable the **`faces`** feature flag in admin settings.
2. Enable **"Detect people in this gallery"** per event.
**Nothing in the backend touches this service while the flag is off**, so an install without this container never attempts a connection.
## Configuration
| | |
|---|---|
| `FACE_ML_TOKEN` | **Required.** The container **refuses to start** without it, so an accidentally published port is never a free face-detection API. Must match the backend's `FACE_ML_TOKEN`. |
Port **8000**, no volumes, no published ports needed — the backend reaches it on the compose network. `FACE_ML_URL` defaults to `http://picpeak-ml:8000` (the compose service name), so the standard deployment needs no URL configuration.
## API
All endpoints except `/health` require the `X-Face-ML-Token` header.
| | |
|---|---|
| `GET /health` | `{"status": "ok"}` — unauthenticated, used by the healthcheck |
YuNet (detection) + FaceNet-512 (embedding), **both MIT**, baked into the image and verified by SHA-256 at build time — never downloaded at runtime, so airgapped installs work and a model cannot change under a running deployment. See [`ml/LICENSES.md`](https://github.com/PicPeak/picpeak/blob/main/ml/LICENSES.md) for why these and not InsightFace's non-commercial weights.
## Not available on the all-in-one image
[`picpeak/aio`](https://hub.docker.com/r/picpeak/aio) sets `PICPEAK_SINGLE_CONTAINER=true` and the backend refuses to enable face recognition there — a second image-processing pipeline competing with thumbnailing for one small container's CPU would not fail loudly, it would just make the install slow. Run the multi-container deployment for this feature.
## Docs
**https://docs.picpeak.app** · sidecar internals, model conversion and the alignment/threshold contract: [`ml/README.md`](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, the all-in-one image and the optional ML sidecar to GitHub Container Registry (ghcr.io). On the canonical org repo every one of them is mirrored to Docker Hub as `docker.io/picpeak/{backend,frontend,aio,ml}`; forks build the same images GHCR-only.
The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is mirrored to Docker Hub (`docker.io/picpeak/aio`) alongside GHCR on the canonical org repo, and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution.
## Features
@@ -10,6 +12,7 @@ This GitHub Actions workflow automatically builds and pushes Docker images for b
- 🔒 **Security scanning** with Trivy vulnerability scanner
- 💾 **Build caching** for faster subsequent builds
- 📊 **Build summaries** in GitHub Actions UI
- 📝 **Docker Hub pages** for `aio` and `ml` synced from `.github/dockerhub/*.md` on every `main` merge (`dockerhub-descriptions` job). `backend` and `frontend` pages are still hand-maintained in the Hub UI — add `.github/dockerhub/{backend,frontend}.md` with their current text before putting them under the same job.
## Authentication
@@ -42,16 +45,21 @@ Once published, images can be pulled using:
// Not a bypass user — this gate doesn't apply to them. They
// go through normal review. Report success so the required
// check doesn't block their merge.
conclusion = 'success';
title = 'Not applicable';
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
} else if (linesChanged <= LINE_LIMIT) {
conclusion = 'success';
title = `OK — within bypass limit (${linesChanged} lines)`;
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
} else {
conclusion = 'failure';
title = `Too large for bypass (${linesChanged} lines)`;
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
# cms_pages, the fingerprint check would silently no-op and this
# workflow would lose its teeth — assert the precondition.
- name:Assert recovery-state fingerprint
env:
PGPASSWORD:testpass
run:|
installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
if [ "$installed" != "2" ]; then
echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
# initializeDatabase() doesn't create the `migrations` tracking
# table — that's the migrate:safe runner's job. So in the recovery
# scenario, the table either (a) doesn't exist yet or (b) exists
# but is empty (e.g. someone created it but didn't populate it).
# Both are valid recovery states; check via to_regclass first so
# we don't parse a SELECT against a nonexistent table.
echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
exit 1
fi
echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
# Step 2: run migrate:safe — the test. Before #530's fix in
# detectExistingSchema, this died at core/029 with a "column
# subject does not exist" error. After the fix, it should complete
# cleanly with every migration either applied or marked.
- name:Run migrate:safe against the recovery state
working-directory:./backend
env:
NODE_ENV:production
DATABASE_CLIENT:pg
DB_HOST:localhost
DB_PORT:5432
DB_USER:picpeak
DB_PASSWORD:testpass
DB_NAME:picpeak_drift
run:npm run migrate:safe
# Step 3: schema-shape assertion. A fresh install through migrate:
# safe produces 48 tables; the recovery scenario should converge
# to the same number. Off-by-one is fine but a 10+ table delta
# means a migration silently bailed in the recovery path.
- name:Assert final schema matches fresh-install shape
env:
PGPASSWORD:testpass
run:|
tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
echo "Final table count: $tables"
# Allow a small drift window — exact count creeps over time as
# new migrations land; tight pin would force a workflow edit
# on every schema PR. 40+ is a healthy floor that catches the
# original bug (which left 17 tables) while staying robust to
# forward changes.
if [ "$tables" -lt 40 ]; then
echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
echo "ok: schema converged to a fresh-install-equivalent shape."
# Step 4: verify the legacy migrations were all marked applied
# (rather than silently bailing inside the chain). The fix in
# detectExistingSchema marks legacy/* when the modern bootstrap
# is detected — confirm the markings actually landed.
- name:Assert legacy migrations marked applied
env:
PGPASSWORD:testpass
run:|
legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
if [ "$legacy_count" -lt 7 ]; then
echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
exit 1
fi
echo "ok: legacy migrations marked applied by detectExistingSchema."
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
## Enforcement
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
@@ -33,12 +33,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
Unsure where to begin? You can start by looking through these issues:
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
### Pull Requests
1.**Fork the repo** and create your branch from `main`
1.**Fork the repo** and create your branch from `main` (active development)
2.**Install dependencies**:
```bash
cd backend && npm install
@@ -50,13 +50,16 @@ Unsure where to begin? You can start by looking through these issues:
- Linting passes: `npm run lint`
4. **Write tests** if you've added code
5. **Update documentation** if needed
6. **Create a Pull Request**
6. **Attach a screenshot for any UI change** (see below)
7. **Create a Pull Request**
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
## 💻 Development Setup
### Prerequisites
- Node.js 18+
- Node.js 22.12.0 or later (matches `backend/package.json`)
- Docker & Docker Compose
- Git
@@ -70,15 +73,33 @@ cd picpeak
# Install dependencies
cd backend && npm install
cd ../frontend && npm install
cd ..
# Set up environment
cp .env.example .env
# Edit .env with your settings
# Start Postgres and Redis (the app itself runs on the host, see below)
docker compose up -d postgres redis
# Start development servers
docker-compose -f docker-compose.dev.yml up
# Backend config — note this is backend/.env, not the root one
cp backend/.env.example backend/.env
# JWT_SECRET must be set: the host process validates it and exits without one.
# (The containers generate it themselves; `npm run dev` does not.)
# Backend, with nodemon hot reload — http://localhost:3001
cd backend && npm run dev
# Frontend, with Vite hot reload, in a second shell — http://localhost:5173
cd frontend && npm run dev
```
Open **http://localhost:5173**. Vite proxies `/api` to the backend on `3001`, so
you do not need the root `.env` for this loop at all — that one configures the
compose stack.
Running the two Node processes on the host is the fastest loop: both reload on save, and you get a real debugger and stack traces without rebuilding an image.
**Prefer everything in containers?** `docker compose up -d` builds `backend`, `frontend` and `ml` from source using the production Dockerfiles. That works, but there is no hot reload — you rebuild on every change (`docker compose up -d --build backend`).
> `docker-compose.dev.yml` is listed in `.gitignore` and is not part of the repo. If you keep a local one for live-mounting `./backend/src` and `./frontend/src` against `backend/Dockerfile.dev` / `frontend/Dockerfile.dev`, remember it bakes `node_modules` into the image: after pulling a change to `backend/package.json`, rebuild that image or you will get a `MODULE_NOT_FOUND` restart loop.
### Running Tests
```bash
@@ -144,17 +165,38 @@ picpeak/
│ └── public/ # Static assets
```
## 🌿 Branch model
PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Security fixes and regular bugfix backports, kept small and free of unrelated features. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → target `main`; regular bug fixes are generally backported automatically to `stable`. Maintainers handle conflicts or create a separate focused backport PR when needed.
- **Security vulnerability** → report privately using [SECURITY.md](SECURITY.md). Security fixes are always released on both `stable` and `main`; coordinate any fix with the maintainers before opening a public PR.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
## 🔄 Release Process
1. Update version numbers in package.json files
2. Update CHANGELOG.md
3. Create a new release on GitHub
4. Docker images are automatically built and published
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
## 📮 Contact
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security vulnerabilities: Follow the [security policy](SECURITY.md) and use [private vulnerability reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- Avoid `$` in passwords (recommended - use the commands above)
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
-`npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env)
Update `.env` with:
-`JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
-`DB_PASSWORD` - PostgreSQL password
-`REDIS_PASSWORD` - Redis password
-`SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
-`FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
-`ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### Authentication Security
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you don’t set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
```bash
cp frontend/.env.example frontend/.env
```
Update `frontend/.env` with:
-`VITE_API_URL` - Backend API URL
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
- The backend API runs on port **3001**
- The frontend `.env` file MUST point to the correct backend port (3001)
- Default `.env.example` is configured for Docker deployment
### Email Configuration Examples
#### Gmail
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
```
#### SendGrid
```env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
```
## 📦 Deployment
### Using Pre-built Images (Fastest)
```bash
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
# View running containers
docker compose ps
```
### Access Points
By default, services are exposed on:
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
### Initial Admin Setup
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
#### Finding the Auto-Generated Admin Password
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
**Option 1: Search Docker logs for admin password** (recommended)
- **Avoid personal information** (names, dates, etc.)
- **Save securely** - you cannot recover this password easily
### If You Lose Access
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
```env
# .env
ADMIN_EMAIL=your-email@yourdomain.com
```
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
### Routing Schema
PicPeak consists of two services that need to be routed correctly:
[](https://buymeacoffee.com/theluap)
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
---
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend,aio,ml}` (and on Docker Hub as `picpeak/{backend,frontend,aio,ml}`) and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
## Contents
- [Live Demo](#-live-demo)
- [Quick Start](#-quick-start)
- [Why PicPeak?](#-why-picpeak)
- [Features](#-features)
- [Documentation](#-documentation)
- [Comparison](#-comparison-with-alternatives)
- [Tech Stack](#️-tech-stack)
- [Contributing & Support](#-contributing)
- [License](#-license)
## 🎮 Live Demo
Try PicPeak without installing anything:
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 📈 **Scalable** - From small studios to large agencies
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
```bash
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
git clone https://github.com/PicPeak/picpeak.git
cd picpeak
# Copy environment template
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose
docker-compose up -d
dockercompose up -d
# Access at http://localhost:3005
# Access at http://localhost:3000
```
Note on Docker file permissions (PUID/PGID)
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a non‑root user by default.
- Set `PUID` and `PGID` in your `.env` to match your host user’s UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
- Example in `.env`:
-`PUID=1000`
-`PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
## 🔄 Release Channels
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
PicPeak offers two release channels for different needs:
### Or: one container, no compose file
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Switching Channels
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
For a home server, a NAS, or a single small studio, the all-in-one image runs the whole app as one process with SQLite — no compose file, no separate database, no reverse proxy to wire up:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
docker run -d --name picpeak -p 3000:3000 \
-v picpeak:/data \
ghcr.io/picpeak/picpeak/aio:main
```
Then update your containers:
No environment variables to set — the JWT secret is generated on first start and kept on the volume.
docker-compose -f docker-compose.production.yml up -d
```
Then open **http://localhost:3000/admin** and read the setup token with `docker exec picpeak cat /data/db/SETUP_TOKEN`, or open `db/SETUP_TOKEN` on the volume with any file manager if the host has no shell.
### Update Notifications
`:main` is the active-development tag, and today it is the only one the all-in-one image has — `Dockerfile.aio` landed after the current stable release, so `:stable` and `:latest` first appear for this image once the aio build reaches the `stable` branch. Switch to `:stable` then, or pin a published version tag if you would rather not track `main`.
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
The compose stack above is still the right choice for anything busier — SQLite takes one writer at a time, and Postgres is what scales. You can move to it later without reinstalling: take a `.picpeak` backup and restore it into the full stack. See **[Single-container install](https://docs.picpeak.app/deployment/single-container)** for the volume layout, the external-Postgres variant, TLS, and the limits.
| ML sidecar (optional) | `ghcr.io/picpeak/picpeak/ml` | [`picpeak/ml`](https://hub.docker.com/r/picpeak/ml) |
Both registries get the same digests and the same tags — `stable`/`latest`, a pinned `x.y.z`, and `beta`/`main` for the active development channel — for `linux/amd64` and `linux/arm64`. Keep every image in one install on the **same** tag.
- **💰 No Monthly Fees** — one-time setup, unlimited galleries
- **🔒 Complete Data Control** — your photos stay on your server
- **🎨 White-Label Ready** — full branding customization
- **📱 Mobile-First Design** — beautiful on all devices
- **🌍 Multi-Language** — built-in i18n (EN, DE)
## ✨ Features
**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, **[People in this gallery](https://docs.picpeak.app/features/face-recognition)** face grouping (opt-in per gallery, needs the optional [ML sidecar](https://github.com/PicPeak/picpeak/blob/main/ml/README.md)), optional guest uploads, and download protection (watermarking + right-click prevention).
**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](https://docs.picpeak.app/features/storage-backends), [webhooks](https://docs.picpeak.app/features/webhooks), and security-first defaults (JWT, rate limiting, CORS).
<details>
<summary><strong>🧾 For studios — CRM & Accounting (Beta, off by default)</strong></summary>
- ⏱️ **Hours Logging & Calendar** — per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** — capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** — period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export
- 🌍 **VAT & Multi-currency** — single VAT-code registry snapshotted onto each document
</details>
> [!WARNING]
> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
- **Storage**: File-based with automatic archiving
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size10G;
proxy_read_timeout3600;
proxy_send_timeout3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<details>
<summary>Click to see the admin dashboard, analytics, and event management</summary>
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🗺️ Roadmap
## 🤝 Contributing
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
### 🚧 Beta Features (Use at your own risk)
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
These features are currently in beta testing and may have limited functionality or stability:
## ☕ Support the Project
| Feature | Description | Status |
|---------|-------------|--------|
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
### 🤖 AI-Assisted Development
### 👥 Contributors
This project was generated with the assistance of AI technology, but has been:
- ✅ **Fully tested end-to-end** by human developers
- 🔒 **Security audited** with comprehensive security checks
- 👨💻 **Human-reviewed** for code quality and best practices
- 🧪 **Production-tested** in real-world scenarios
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
**[@the-luap](https://github.com/the-luap)** — creator and lead maintainer
- Gallery foundation (events, uploads, sharing, download protection, templates)
- Backup & restore, analytics, branding/theming
- The architecture every later feature builds on
**[@Luca-Timo](https://github.com/Luca-Timo)**
- Native Apple Silicon multi-arch images
- CRM & accounting suite (quotes/contracts/invoices)
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
## TL;DR
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 4–6 weeks**, or sooner if `main` has been quiet and ready for promotion.
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
## Cadence target
4–6 weeks between stable releases is the working target. Reasoning:
- Long enough that each stable carries meaningful changes worth the upgrade burden.
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 4–6 week window, which gives natural promotion candidates).
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
## Promotion criteria
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
1.**CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2.**No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
3.**An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
4.**Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
## How a stable release is cut
The actual mechanics, in order:
1.**Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
2.**Create the release branch from the `main` tip.**
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
Regular bug fixes are generally backported automatically from `main` to `stable`. Keep backports focused on the fix, without unrelated features, and resolve conflicts manually when needed.
**Security fixes are always released on both `stable` and `main`.** Do not wait for a full promotion to deliver a security update. A fix first applied to `stable` must also be forward-ported to `main`; a fix first applied to `main` must also reach `stable`. See [SECURITY.md](SECURITY.md) for the support policy.
When a backport needs manual handling:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `stable` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
6. For security fixes, verify that the fix has been published through **both** release channels; merging the code is only part of delivery.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
## Versioning
PicPeak follows [Semantic Versioning](https://semver.org/) with one project-specific convention:
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
### Stable ↔ pre-release number alignment
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
- **Test-only changes** — same.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
## When this doc is wrong
If you find yourself working around something here, update the doc before doing the workaround. The point of a written process is that future-you doesn't have to remember the workaround.
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
4. Upload photos via drag & drop in the Photos tab
5. Publish the gallery when ready
#### Adding Photos via File System
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
constsettle=async()=>{for(leti=0;i<60;i++){awaitnewPromise((r)=>setTimeout(r,50));consts=awaitstatus();if(!s.body.isRunning)returns;}thrownewError('backfill did not settle');};
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.