b3937d0b8c54c8ddf8a428be88aa444ab2b4f2d2
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7ca783f89b | fix(usage): preserve report contracts with compatible receiver validation | ||
|
|
a6a1db5254 |
fix(gallery): release grid tiles once they are far enough out of view
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 |
||
|
|
c358bc65f7 | feat(usage): add consented beta capabilities and gallery photo totals | ||
|
|
e40bc474bc |
fix(usage): let an operator clear a participation the collector never accepted
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. |
||
|
|
c741dc22c5 |
docs(usage): state in the consent dialog that the connection only runs outwards
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. |
||
|
|
1e8b6f1b0f |
fix(usage): close the QA findings on opt-in product usage
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. |
||
|
|
a7382591bf | feat: expand opt-in capability coverage with versioned consent | ||
|
|
5d31b61c8d | Merge remote-tracking branch 'origin/main' into codex/1110-usage-coverage | ||
|
|
e347f8f40f | fix(usage): minimize session receipts and clarify privacy controls | ||
|
|
7ff8caf9d7 |
fix: remove the fragmentation handling stranded by #1303
#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 |
||
|
|
cc263f2e87 |
fix(usage): isolate the Postgres fixture, and stop two more wrong signals
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 |
||
|
|
32d745b575 |
fix(usage): stop local backups implying S3 use, and make the protocol-error branch reachable
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 |
||
|
|
c7cedb00d6 |
test(usage): prove product usage works on PostgreSQL, and harden the collector default
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 |
||
|
|
75ef137b7d |
fix(usage): drop the tinted block and stop the modal opening with a focus ring
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
|
||
|
|
a9e51d8fd7 |
fix(usage): reformat the consent modal so the disclosure can be read
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 |
||
|
|
bb76ca5375 |
fix(usage): keep the settings tab usable on a bad collector URL, and report layouts and CSS accurately
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 |
||
|
|
9785b636a9 |
fix(usage): take the withdrawal baseline before the lease, not after it
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 |
||
|
|
22da018e1b |
fix(usage): close the remaining withdrawal races, reset per-item name consent
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 |
||
|
|
80e238f0ad |
fix(usage): let a withdrawal win against an activation that is still starting
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 |
||
|
|
4944b9b3b6 |
fix(usage): scope the participation notice, highlight it, and call ignoring what it is
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 |
||
|
|
83fbb63e13 |
fix(usage): protect a pending withdrawal, widen the backup signal, explain an unreadable key
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
|
||
|
|
c043897b0e |
fix(usage): name the unreadable-key failure, unpin the collector default, align the tab
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 |
||
|
|
1151e96144 |
fix(security): validate CSS urls last, after every pass that moves text
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 |
||
|
|
027afb6086 |
fix(security): re-check inline CSS after template substitution
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
|
||
|
|
4196e83a5f |
fix(security): use CSS whitespace, not JavaScript's, in the url() reader
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
|
||
|
|
b6dc0991ce |
fix(security): check for an escaped identifier before consuming the escape
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
|
||
|
|
7b4a65ecc7 |
fix(newsletters): make the warning's duration and queue claim honest
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. |
||
|
|
1cf82746b7 |
fix(security): close two CSS url() bypasses the sanitizer dedup exposed
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
|
||
|
|
b53e5d97b4 | feat: add opt-in product usage and feedback integration (#1110) | ||
|
|
967224c030 |
fix: remove the image-fragmentation surface
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. |
||
|
|
933f2d8e0e |
fix(security): reject array values for every field on the event update
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 |
||
|
|
49197be329 |
feat(newsletters): warn about deliverability before a large send
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. |
||
|
|
8f3436f17d |
fix(security): reject array values on the event update route too
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 |
||
|
|
0deef2584f |
fix(security): one settings decoder, and the last creation path
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 |
||
|
|
0e560ebb19 |
fix(security): decode settings at the API boundary and honour the transaction
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 |
||
|
|
19c518aaa5 |
fix(security): close the remaining image-security default gaps
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 |
||
|
|
99f54a3954 |
fix(security): strip control characters before scanning CSS for url()
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 |
||
|
|
fbe9757a53 |
fix(gallery): release the canvas decode when it is drawn, not at unmount
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 |
||
|
|
ab6c33d9eb |
fix(security): apply image-security defaults on every creation path
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 |
||
|
|
e734e41c41 |
fix(gallery): remove the inert image-protection prop surface from AuthenticatedImage
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 |
||
|
|
8ca3610514 |
fix(security): apply the Image-security defaults instead of storing them (#1296)
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. |
||
|
|
b1e5287351 |
fix(gallery): give the Grid layout a lazy-loading pre-load band (#1287)
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. |
||
|
|
be8d79e9c4 |
fix(gallery): release the canvas-mode decode, and drop a now-duplicate sanitizer
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. |
||
|
|
659aa77a9e |
docs: move the upload file-types page to the docs repository
The in-repo page and README row duplicate what docs.picpeak.app carries; the Settings help text stays here. |
||
|
|
f3b062a3a7 |
docs: document the upload allow-list and the chunked-upload type rule
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. |
||
|
|
6350f86907 |
chore(deps): apply non-breaking npm audit fixes
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). |
||
|
|
0ac006bb95 |
fix(security): chunked-upload init checks the size cap before the type allow-list
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. |
||
|
|
835312e8e6 |
fix(security): harden four smaller gallery and contract paths, drop the unmounted photo auth middleware
- 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 |
||
|
|
40a8a9882a |
fix(security): stop reflecting submitted values in validation errors everywhere, cap credential lengths, close the login timing oracle
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.
|
||
|
|
839bf4e464 |
fix(security): close four middleware gaps around the API edge
- 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 |
||
|
|
063977d97d |
fix(security): never serve a photo under its stored MIME, and stop trusting the chunked-upload type
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. |
||
|
|
3e46530072 |
fix(security): contain logo, favicon and PDF-logo unlinks to their upload directories
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.
|
||
|
|
0ca0e4a922 |
fix(security): verify the signature before writing a token to the revocation list
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. |
||
|
|
903e471753 |
fix(security): stop reflecting submitted passwords in validation errors
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.
|
||
|
|
054cd6f82f |
fix(security): enforce the strength-endpoint validators, and stop the generator spinning
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.
|
||
|
|
14cd5eacb3 |
fix(security): bound password input before zxcvbn, and drop the legacy media mounts
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.
|
||
|
|
0b6b8fbdb0 |
fix(gallery): follow the input in use, not the device's primary pointer
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. |
||
|
|
2d403f7fb2 |
fix(email): wire the settings status card, and cap-aware truncation
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. |
||
|
|
4deac229ac |
fix(email): make waiting rows read-only, and time the grace from when due
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. |
||
|
|
98aa06aeff |
fix(email): read naive SQLite timestamps as UTC, and page the candidates
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. |
||
|
|
bc90b4db62 |
fix(crm): label the two invitation conflicts and stop guessing after a 5xx
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. |
||
|
|
89db469f06 |
fix(email): compare queue timestamps in JS, and make retry actually send
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. |
||
|
|
6bb12c6612 |
fix(crm): stop the invitation UI claiming more than it can know
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. |
||
|
|
d0274886e6 |
fix(gallery): decide the overlay by pointer capability, not viewport width
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. |
||
|
|
1b8e5f83d7 |
fix(crm): tell the admin whether a customer's invitation actually went out
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. |
||
|
|
73d867521a |
fix(email): show a queue nobody is working instead of reporting all-clear
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. |
||
|
|
c0d34796cd |
fix(gallery): stop invisible overlay controls swallowing mobile taps
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. |
||
|
|
28f14955e2 |
fix(gallery): clear the guest identity on gallery logout
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. |
||
|
|
f2f40893c1 |
fix(guests): drop a stored identity when a spent invite names someone else
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. |
||
|
|
7a1ea842e4 |
fix(guests): read identity from whichever store holds it, write it as a pair
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. |
||
|
|
7d51aa3db9 |
fix(guests): don't answer feedback with a stale identity mid-invite
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. |
||
|
|
e9babf65e7 |
fix(guests): rebuild consumers on identity switch; repair fallback reads
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. |
||
|
|
f3f37a8c77 |
fix(guests): invite wins over stored identity; clear server-rejected ones
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. |
||
|
|
51db1e09e9 |
fix(guests): expire stale tokens, sync tabs, survive unwritable storage
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. |
||
|
|
a21c4d3bf5 |
fix(guests): keep guest identity across a tab close
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. |
||
|
|
19e125d814 |
fix(security): raise the general limiter's fallback budget to 300
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. |
||
|
|
23a433f411 |
docs(analytics): state the tracker proxy's trust model
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. |
||
|
|
a912817ec8 |
refactor(archives): use the shared LIKE escape helpers
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. |
||
|
|
5a0c9f53b0 |
fix(security): rate-limit the password-change endpoints per IP too
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. |
||
|
|
4515632300 |
fix(migrations): judge each German field on its own in migration 195
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). |
||
|
|
a929affd7e |
fix(security): close the case-sensitivity bypass in the API rate limiter
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. |
||
|
|
50e8ed6e58 |
fix(security): apply per-IP rate limiting to credential endpoints
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.
|
||
|
|
30ac4140af |
chore(backend): teach eslint the rest-sibling omission idiom
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. |
||
|
|
4646d5de69 |
i18n: drop the keys this branch orphaned
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. |
||
|
|
4c6ca49b17 |
fix(gallery): restore the download CTA under headerStyle "none"
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. |
||
|
|
504a8b6fae |
fix(events): honour ?tab=, show a load error, and stop lying about uploads
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. |
||
|
|
15fdd70a08 |
fix(search): match the original filename, and honour the date-format setting
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.
|
||
|
|
b0f33c1744 |
fix(security): actually apply the general API rate limiter
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.
|
||
|
|
a89057df1d |
fix(search): stop escapeLikePattern corrupting bound search values
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. |
||
|
|
413290af3e |
i18n: fail safe on empty strings, normalise German to Sie
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. |
||
|
|
758dc005df |
fix(events): rename a shadowing local and bound the photo-cap input
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.
|
||
|
|
be39929476 |
fix(accounting): allow creating a customer from the picker
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. |
||
|
|
34685505be |
fix(analytics): serve self-hosted trackers same-origin so CSP stops blocking
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. |
||
|
|
fe5ac9162d |
fix(photos): emit visibility and processing_status from the list mapper
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. |
||
|
|
3acb452090 |
fix(settings): remove the duplicated section heading on 11 tabs
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.
|
||
|
|
22cada9082 |
fix(ui): drop themed text colours from the last three admin surfaces
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
|
||
|
|
bd44708a03 |
fix(contracts): widen the block-library list column
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.
|
||
|
|
a28f96b304 |
fix(gallery): no-store private JSON, and give guest uploads a real status
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.
|
||
|
|
7c9baff751 |
fix(upload): scope category ids, stop temp-file leaks, split the video cap
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. |
||
|
|
57dd084763 |
fix(events): add archive_size to the immutable column deny-set
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. |