e734e41c412cede1be5efbe27aab617d59faee9d
2246 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
c71ffae912 |
chore(main): release 3.123.0-beta.0 (#1294)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Successful in 9m21s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Successful in 9m53s
Build and Push Docker Images / smoke-aio (push) Failing after 11m17s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Successful in 12m38s
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
|
||
|
|
fc595409b4 |
feat(crm): newsletter campaigns behind a newsletters flag (#1264)
Part B of #1264. Flag off by default, so an install that never enables it gains no route, no nav entry and no way to mass-mail. A campaign is a body plus a recipient rule. Queueing one writes ordinary email_queue rows (email_type 'newsletter', origin 'campaign', new campaign_id), so retry, rendered_html, sent_at and error_message all come from the existing processor rather than a parallel sender. Throttling staggers scheduled_at; the processor loop is untouched. Two rules the service enforces: no raw HTML is ever stored (sanitized on write and again on render, idempotently), and opt-out is checked at queue time AND again at send time. Migration 199 adds email_campaigns, email_campaign_recipients, email_queue.campaign_id, customer_accounts.marketing_opt_out(_at), and the newsletters.view / newsletters.send permissions. Three rounds of external review are folded in, including several that would otherwise have shipped broken: - Campaign rows never came due on SQLite. queueEmail writes a Date, which the sqlite3 binding stores as epoch ms; ISO text in the same column compares as TEXT against an INTEGER, and SQLite orders every INTEGER below every TEXT. The feature silently sent nothing there. - The flag had no Settings card and no sidebar entry, so it could not be enabled through the UI at all. - Consent is per ADDRESS, not per row: two accounts sharing an inbox meant unsubscribing stopped one and not the other, at both queue and send time. - The unsubscribe GET mutated consent, so a mail-security scanner walking a campaign could have unsubscribed much of the list. GET now confirms, POST acts. - The rate ceiling is clamped to the queue's real throughput (10/min), so the composer's estimate stops being wrong by up to 12x. Closes #1264 |
||
|
|
a7d0972b13 |
fix(security): make the CSS sanitizer's remote-URL block actually block
sanitizeCSS "blocked" a remote url() by prefixing it with a /* BLOCKED URL */ COMMENT and leaving the URL in place. CSS comments are discarded during tokenization, so the declaration a browser parsed still carried the live URL — while adminCssTemplates returned sanitization_warnings claiming it had been stopped. Protection that reports success is worse than none, which is why it survived review. Scope is narrow: sanitizeCss (lowercase, the public-site path) never included the pattern and permits remote URLs by design — a test now pins that. Only sanitizeCSS (uppercase) was affected; outside this repo's newsletter branch its sole caller is adminCssTemplates.js. Migration 200 is required, not cosmetic: gallery.js serves css_templates.css_content VERBATIM as text/css and does not re-sanitize on read, so fixing the write path alone would leave every existing template serving its URL forever. Review follow-ups replaced the regex with a small three-state lexer (comment / string / identifier) over the RAW text, after five further bypasses: a ")" inside a quoted url(), CSS escapes (u\72l), the HTML comment strip JOINING tokens into a live url() after the scan, an escaped quote desynchronising the scan, and a quote inside a comment. Escapes are decoded only to decide, never to rewrite — a clean input now round-trips byte-identical, which also keeps unaffected rows out of the migration's write path. Severity is low (writing a template needs branding.edit) but the harm is a gallery visitor's IP reaching a third party from a page the operator believes carries no remote requests. |
||
|
|
de3a7f70bf |
fix(cms): enable the Tailwind typography plugin so prose classes work (#1288)
tailwind.config.js had plugins: [] and @tailwindcss/typography was never
installed, so every prose / prose-neutral / dark:prose-invert class in the
app resolved to nothing. Preflight, which IS active, resets h1-h6 to
inherit size and weight and strips list-style from ul/ol — so an applied
<h2> rendered pixel-identical to the <p> it replaced.
The editor was never broken. The toolbar highlighted because
editor.isActive('heading') correctly returned true; only the CSS to show
it was missing. That also explains why pasting rendered rich text worked:
it carries inline styles.
Ten surfaces rely on these classes, including the PUBLIC CMS pages — so
impressum/datenschutz were serving unstyled headings to visitors too.
Review follow-ups: prose colours are mapped to the theme tokens wherever
.text-theme marks theme-owned text (a dark gallery preset sets
--color-text but no .dark class, so dark:prose-invert never engages and
headings would have gone near-black on dark); code blocks inherit rather
than being scaled twice; and H5/H6 get explicit rules, since the plugin
only styles h1-h4.
Closes #1288
|
||
|
|
4afe7a6f08 |
fix(gallery): bound concurrent image fetches and abort them on unmount (#1287)
Hardening for the large-gallery stall. The reporter could not isolate the
cause and neither could I from static reading; these are two defects that
are wrong independently of whether they are the whole story.
Gallery grids are NOT virtualized: a 546-photo event puts 546 PhotoCards
in the DOM, each mounting its own bare fetch. Two problems there: no cap,
and a cleanup that only set a flag while the request kept running.
withImageFetchSlot now holds requests to six in flight, with the BODY read
inside the slot — fetch resolves on headers, so releasing there would have
bounded header round-trips and nothing else. Teardown aborts via
AbortController.
A request queued indefinitely is PENDING, not failed, which is why the
failure left no console error, no failed request and nothing in the
backend log.
Review follow-ups: three tiers (current lightbox slide > neighbour
prefetch > grid thumbnails), because a single FIFO put the image the user
just clicked behind hundreds of thumbnails; and a synchronous throw now
releases its slot instead of permanently draining the pool.
If it recurs, capture performance.getEntriesByType('resource') for the
stalled thumbnails — a queued request shows responseStart === 0.
Relates to #1287
|
||
|
|
8f98f6bdec |
fix(gallery): show a guest their own likes when feedback sharing is off (#1286)
show_feedback_to_guests means "don't show guests OTHER PEOPLE's feedback". The per-viewer is_liked flag was gated on it anyway, so turning sharing off emptied every heart the guest had set themselves, on every page load, while the photo_feedback rows sat there intact. The query behind the flag is filtered to the viewer (by guest_id, or by their own IP+UA identifier), so what it returns was never aggregate data. The colour-label block twelve lines below already documents this exact reasoning and is correctly ungated. Scope is just that flag — the counts beside it stay gated, with a test pinning that the fix does not leak them back. The #1150 contract still holds: an admin-hidden like does not read as liked. Note for the reporter: the FILTER path was already correct (includeGuestMatches is ungated, /my-feedback carries no gate). The empty Likes chip was downstream of the same falsified flag, not a second bug. Closes #1286 |
||
|
|
b6e40b9a2a |
feat(email): global signature footer from the business profile (#1264)
The business profile already carried the operator's full issuer block —
address, phone, email, website, VAT id — but none of it reached an email.
Those columns only fed the quote/invoice PDF renderer, so every outgoing
mail footer was the fixed logo + company name + copyright line.
The signature is rendered by wrapEmailHtml and nowhere else, so no
template, no per-type send path and no queue row needed a change. Two new
columns on business_profile (migration 198) carry the toggle and one
free-text legal line; everything else is read from the address fields the
operator already maintains.
Default off, with a test pinning that the disabled path is byte-identical
to a no-profile install.
Includes three rounds of external review fixes: the plain-text MIME part
also carries the signature; string booleans ('false'/'0') no longer
invert the toggle; the status line stays silent rather than asserting
"off" while unauthorised or loading; and the preview's Text tab mirrors
the send path's htmlToText fallback.
Manual Messages replies deliberately keep no signature — they bypass the
wrapper by design — and the UI copy names that exception.
Closes #1264 (Part A)
|
||
|
|
90da797e3f |
chore(main): release 3.122.7-beta.0 (#1282)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 11s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
|
||
|
|
ea394b5ee5 |
Merge pull request #1280 from PicPeak/fix/security-scan-batch-1
fix(security): batch 1 — zxcvbn DoS, revocation forgery, unlink traversals, stored Content-Type, edge middleware |
||
|
|
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.
|
||
|
|
dc5bccba05 |
chore(main): release 3.122.6-beta.0 (#1279)
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 11s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
|
||
|
|
a87e688484 |
Merge pull request #1277 from PicPeak/fix/1275-hybrid-pointer-input-mode
fix(gallery): follow the input in use, not the device's primary pointer (#1275) |
||
|
|
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. |
||
|
|
30fa320dc2 |
chore(main): release 3.122.5-beta.0 (#1276)
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / smoke-aio (push) Failing after 10s
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
|
||
|
|
b9c29fcf9b |
Merge pull request #1274 from PicPeak/fix/1261-crm-invitation-visibility
fix(crm): tell the admin whether a customer's invitation actually went out (#1261) |
||
|
|
ec1df704b4 |
Merge pull request #1273 from PicPeak/fix/1262-email-queue-visibility
fix(email): show a queue nobody is working instead of reporting all-clear (#1262) |
||
|
|
db197e7685 |
Merge pull request #1272 from PicPeak/fix/1263-mobile-photo-tap-collisions
fix(gallery): stop invisible overlay controls swallowing mobile taps (#1263) |
||
|
|
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. |
||
|
|
ca8050899b |
chore(main): release 3.122.4-beta.0 (#1270)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 11s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 11s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
|
||
|
|
f722bdaf4b |
Merge pull request #1268 from PicPeak/fix/1265-guest-identity-persistence
fix(guests): keep guest identity across a tab close (#1265) |
||
|
|
63fa05b181 |
chore(main): release 3.122.3-beta.0 (#1269)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-ml (linux/amd64, ubuntu-latest) (push) Has been skipped
Build and Push Docker Images / smoke-aio (push) Failing after 9s
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-aio (linux/amd64, ubuntu-latest) (push) Failing after 10s
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / build-aio (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-aio (push) Has been cancelled
Build and Push Docker Images / build-ml (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-ml (push) Has been cancelled
Build and Push Docker Images / dockerhub-descriptions (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
|
||
|
|
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. |