diff --git a/.env.local.example b/.env.local.example index 273808d..00cdb42 100644 --- a/.env.local.example +++ b/.env.local.example @@ -34,6 +34,11 @@ STALE_MAX_AGE=86400 # refetch. Leave it unset in normal operation. # IMAGE_CACHE_VERSION=1 +# Client proofing links: POST a JSON notification here when a client submits +# a selection (Discord, Slack, Gotify, n8n, ...). See +# docs/gallery-config.md#client-proofing-links. +# PROOFING_WEBHOOK_URL=https://discord.com/api/webhooks/... + # CDN mode: serve photos and videos through a pull CDN (default: off). # Point a CDN (Cloudflare, Bunny, CloudFront, ...) at this server as its origin, # cache /api/image/* and /api/video/* including the query string, and put its diff --git a/.gitignore b/.gitignore index e22c502..142eb55 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,7 @@ content/.backups/ # gets its own .backups/ — the top-level pattern above does not reach it. content/**/.backups/ content/analytics.json +content/proofing.json content/*.screenshot-bak # Credentials written by the /install wizard (never committed) content/install.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7212aa2..2c29728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,466 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Releases up to and including v0.9.2 are documented in the [GitHub releases](https://github.com/ralksta/immich-folio/releases). +## [0.19.0] — 2026-09-29 + +### Security + +- **Photo and video URLs stop working once the photo is no longer on the + site** ([#737](https://github.com/ralksta/immich-folio/pull/737), + [#787](https://github.com/ralksta/immich-folio/pull/787), + [GHSA-gfh4-6275-9gqv](https://github.com/ralksta/immich-folio/security/advisories/GHSA-gfh4-6275-9gqv), + low). Asset tokens are deterministic and never expire, so a media URL that + was ever served kept working after its album was unpublished or a client + proofing link was revoked or expired. `/api/image`, `/api/video` and + `/api/exif` now answer `404` (`no-store`) for any asset the site does not + currently show; signed-in admins still pass. The check follows content-file + changes on the next request, and the first views of a newly published album + wait for its rebuild instead of being refused. Copies already held by a + browser or CDN are not recalled. + +- **Subpages taken offline with `enabled: false` are unpublished everywhere** + ([#755](https://github.com/ralksta/immich-folio/pull/755), + [GHSA-w3wp-6qff-q96p](https://github.com/ralksta/immich-folio/security/advisories/GHSA-w3wp-6qff-q96p), + medium). An offline subpage keeps its configuration, so its albums stayed on + the album allowlist: the map still showed them, and the media and download + routes still served their photos and ZIPs, while the page itself answered 404. The map, the published-asset check and the download checks now consider + only albums that are standalone or on an enabled subpage. `hidden: true` + subpages are unchanged: reachable by link, their photos still load. + +- **The sitemap no longer lists password-protected journal entries** + ([#759](https://github.com/ralksta/immich-folio/pull/759), + [GHSA-7c42-5pgh-xppr](https://github.com/ralksta/immich-folio/security/advisories/GHSA-7c42-5pgh-xppr), + low). It already left out protected subpages, albums and content pages, but + filtered journal entries on `draft` alone. It now matches what the journal + index shows a visitor who has not unlocked the entry. + +- **Share cards and page titles carry only text the site wrote** + ([#767](https://github.com/ralksta/immich-folio/pull/767), + [#783](https://github.com/ralksta/immich-folio/pull/783)). `/api/og` + rendered any `title` and `subtitle` from its query string, on the site's + domain and in its accent colour. Card URLs are now signed with the site + secret; an unsigned or altered one gets the plain site-title card, so + previews already cached by social networks keep working. Page metadata no + longer falls back to the requested path: unknown paths get the not-found + title and no card of their own, and locked albums and subpages the generic + protected title with `noindex`. + +- **A locked journal entry's title stays off its password gate** + ([#765](https://github.com/ralksta/immich-folio/pull/765)). The index and + the page metadata already hid it; the gate itself printed it. It now shows + the generic journal title, and a subpage that embeds a locked entry through + `essayFile` shows its own title. + +- **Password attempts no longer reveal which pages have a password** + ([#769](https://github.com/ralksta/immich-folio/pull/769), + [#785](https://github.com/ralksta/immich-folio/pull/785)). `POST /api/auth` + answered a key without a password with a different status and message than a + wrong password, and much faster than a key with an scrypt hash. That gave + away drafts and offline subpages, which otherwise answer 404. Both cases now + get the same `401` and run exactly one scrypt verification. Input validation + and the rate limit are unchanged. + +- **ZIP downloads are capped per visitor, and stalled ones are closed** + ([#774](https://github.com/ralksta/immich-folio/pull/774)). The album and + proofing ZIP routes limited how often an archive starts, not how long one + stays open, so a client that read slowly or stopped reading held an Immich + connection and its buffers for as long as it liked. At most two archives per + client IP may be open at once, shared between both routes (a third gets + `429` with `Retry-After: 30`), and an archive whose client takes nothing for + 60 seconds is torn down. A download that keeps reading is never cut off, + however slowly it reads. See the upgrade notes. + +- **The Immich webhook caps its request body before checking the signature** + ([#780](https://github.com/ralksta/immich-folio/pull/780)). The body was + read in full, at any size, before the HMAC check. It is now limited to 64 + KiB (`413` above), the signature is computed over the raw bytes, and the + signature header must be exactly 64 hex characters; a valid digest followed + by junk was accepted before. + +- **The ZIP refusal page's back link stays on the site** + ([#786](https://github.com/ralksta/immich-folio/pull/786)). A `Referer` + whose path started with `//` became a protocol-relative link to another + host; such paths are now rejected. The link compares the `Referer` with the + `Host` header (and `X-Forwarded-Host` only when `TRUSTED_PROXY_HOPS` is + set), so behind a reverse proxy it now leads back to the album instead of + always to `/`. + +- **Client proofing tokens are never written to the view statistics** + ([#748](https://github.com/ralksta/immich-folio/pull/748)). The cookieless + counter stored every path as visited, and in `/proof/` the token is + the link's only credential. Those visits are counted as `/proof`. + +### Added + +- **Client proofing links** + ([#683](https://github.com/ralksta/immich-folio/pull/683), + [#758](https://github.com/ralksta/immich-folio/pull/758)). Under the new + _Proofing_ tab the admin creates a private link, `/proof/`, per + client and album — any Immich album, published on the site or not — with an + optional expiry date, an optional ZIP download (the selection or the whole + album) and an optional download limit. The client hearts photos as on a + public album; the selection is saved on the server as they go, so they can + stop and continue later or on another device, and _Submit selection_ locks + it. The photographer watches the picks come in, reopens a submitted + selection, resets the download counter or revokes the link, and exports the + picks as a Lightroom or Capture One filename filter (which finds the RAW as + well as the JPEG), CSV or TXT. `PROOFING_WEBHOOK_URL` gets one JSON POST on + the first submit, shaped for Discord, Slack, Gotify and generic receivers. + Links are stored in `content/proofing.json` (mode `0600`); proof pages are + `noindex`, send no `Referer` and stay out of the sitemap, and a site + password still applies. The selection ZIP waits until pending hearts are + saved, so it never misses the last pick. The anonymous proofing on public + albums is unchanged. See `docs/gallery-config.md#client-proofing-links`. + +- **Custom content pages** + ([#738](https://github.com/ralksta/immich-folio/pull/738), + [#760](https://github.com/ralksta/immich-folio/pull/760), + [#764](https://github.com/ralksta/immich-folio/pull/764), closes + [#722](https://github.com/ralksta/immich-folio/issues/722)). + `content/pages/.md` — frontmatter plus the journal's block markdown — + is served at `/` and rendered like a journal entry without date or + reading time. A `- page: ` line among the subpages in `gallery.yaml` + puts it in the menu at that position; a page without one is reachable by + link but not listed. Pages can be drafts (visible to signed-in admins only) + and can carry a password (cookie `lb_auth_page_`); public pages appear + in the sitemap. In the page builder, pages sit in one _Menu_ group with the + subpages, _+ New page_ derives the slug from the title with a collision + check, and the content is edited in the journal studio. Renaming or deleting + a page updates its menu entry, with a backup first; page backups rotate in + `content/pages/.backups/`. A new doctor check, `content-pages`, reports slug + collisions and menu entries without a file. A missing or draft page is still + a soft 404 (status `200`), like other slugs on this route. + +- **The asset picker searches the library and picks several photos at once** + ([#740](https://github.com/ralksta/immich-folio/pull/740), + [#752](https://github.com/ralksta/immich-folio/pull/752), closes + [#602](https://github.com/ralksta/immich-folio/issues/602)). A date (`2024`, + `2024-05`, `2024-05-17`) searches by capture date, any other text by file + name and Immich description. Tiles are buttons with a numbered badge in pick + order, and a bar at the bottom offers _Add N photos_. The homepage hero + takes several at once, _+ Photos_ in the journal and page editor adds one + photo block per pick, and pairs and grids fill their slots. The search stays + read-only against Immich. + +- **Admin editors detect concurrent edits** + ([#742](https://github.com/ralksta/immich-folio/pull/742), + [#757](https://github.com/ralksta/immich-folio/pull/757), closes + [#601](https://github.com/ralksta/immich-folio/issues/601)). Every editor + that saves a content file — gallery, settings, About, privacy, journal + entries and content pages — notices when the file changed since it was + loaded, in another tab or on another device, by hand or through a backup + restore. Instead of overwriting it silently, it offers _Reload (discard + mine)_, _Overwrite anyway_ or _Keep editing_. A save from a tab opened + before the upgrade still goes through as before. Deletes are not + version-checked. + +- **Settings set by the environment are shown as locked** + ([#739](https://github.com/ralksta/immich-folio/pull/739), closes + [#605](https://github.com/ralksta/immich-folio/issues/605)). `sitePassword` + (overridden by `SITE_PASSWORD`) and `contact.notifyUrl` + (`CONTACT_NOTIFY_URL`) are disabled in the settings editor with a note + naming the variable, and a save keeps what `settings.yaml` holds for them. + `SITE_TITLE`, `SITE_SUBTITLE` and `SITE_URL` are fallbacks, not overrides, + and stay editable. + +- **An accessibility baseline** + ([#741](https://github.com/ralksta/immich-folio/pull/741), closes + [#696](https://github.com/ralksta/immich-folio/issues/696)). The hero + carousel stops auto-advancing under `prefers-reduced-motion` and has a pause + button, and one reduced-motion rule covers the site, the admin and + `/install`. Theme toggle, menu button and scroll-to-top are 44×44 px + targets; EXIF and cover overlays also show on keyboard focus, and always on + touch screens. The lightbox keeps Tab inside and returns focus to the photo + shown last, the mobile menu moves focus in and back out, and the admin and + `/install` have skip links. Photo counts in descriptions and share cards, + the lightbox position and the _Gallery_ fallback title are translated. + Header and hero navigation now share one order — Home, subpages and content + pages in `gallery.yaml` order, standalone albums, Journal, About, Map — with + external `navLinks` last. + +### Changed + +- **Less work per request** + ([#753](https://github.com/ralksta/immich-folio/pull/753), + [#756](https://github.com/ralksta/immich-folio/pull/756), + [#749](https://github.com/ralksta/immich-folio/pull/749), + [#761](https://github.com/ralksta/immich-folio/pull/761), + [#775](https://github.com/ralksta/immich-folio/pull/775)). The configuration + is derived once per content-file change instead of on every read (one + `/api/image` request did it seven times), and the Immich client reads it + once per operation. Album blocks in journal entries, content pages and + essays go through the album cache with request coalescing, and a page's + blocks load in parallel instead of one after another. Parsed journal and + page listings are kept while their files are unchanged, and the journal and + site navigation are read once per request. Content edits still show on the + next request; album blocks now follow `CACHE_TTL` like every other album. + +- **One image URL per photo and size tier** + ([#762](https://github.com/ralksta/immich-folio/pull/762)). The proxy + answers every width within a tier with the same bytes, but `next/image` + wrote ten widths into each `srcset` and the lightbox asked for an eleventh + spelling, so opening a grid photo downloaded the same preview again, and so + did rotating a phone. Grid, hero, essay and lightbox now request one URL per + tier, and the unused `q` parameter is gone. An album viewed in full in the + lightbox transfers each preview once instead of twice, and a CDN sees at + most one miss per photo and tier. The URLs change once; see the upgrade + notes. + +- **Pages ship less code they do not use** + ([#770](https://github.com/ralksta/immich-folio/pull/770), + [#766](https://github.com/ralksta/immich-folio/pull/766)). The proofing UI + loads only on pages where proofing is on, and Leaflet's stylesheet and map + wrapper only when an essay contains a map block. Album, subpage and journal + pages drop 3–5 KB of gzipped JavaScript and 14% of their render-blocking + CSS. + +- **Settings the site would ignore are refused on save** + ([#795](https://github.com/ralksta/immich-folio/pull/795)). A site URL + without a scheme, an accent that is not hex, or grid columns and gap outside + their bounds were saved with a success message and then silently replaced + when the site rendered. The settings form now marks such fields as you type, + the save answers `400` naming them and writes nothing, and a new doctor + check, `settings-values`, reports values already in `settings.yaml` that the + site ignores. + +- **Admin colours: blue accent, amber warnings, readable muted text** + ([#795](https://github.com/ralksta/immich-folio/pull/795)). Outside Studio + Modern the admin accent was the same amber as its warnings, so every active + marker and focus ring read as a warning; it is now blue. The unsaved-changes + bar and the order editor's drift notice use the warning colour instead of + the accent (red under Studio Modern), the status badge has its own amber + tone for doctor warnings, and muted text reaches 4.5:1 on every admin + surface in every preset. + +### Fixed + +- **Public pages follow admin saves on the next request** + ([#790](https://github.com/ralksta/immich-folio/pull/790)). After a save, + changed album titles, descriptions and slugs, and newly published albums, + could take up to `CACHE_TTL` (five minutes by default) to appear, and a new + album answered 404 meanwhile: the save cleared a copy of the Immich cache + that the public pages never read. The cache now holds only Immich's data and + applies `gallery.yaml` on the way out, and it is one store per process, so + _Clear cache_ in Diagnostics, the webhook and the status panel act on the + cache the pages use. A subpage's cover grid and its albums' previous/next + links follow the order from `gallery.yaml` and the page builder instead of + Immich's. See the upgrade notes. + +- **Photo captions with parentheses no longer break journal entries** + ([#788](https://github.com/ralksta/immich-folio/pull/788)). A caption like + `Summit (560 m)`, or one with a link, ended the photo block at the first + `)`: the public page rendered the line as a paragraph showing raw asset IDs, + and the editor turned it into a text block, dropping the photo on the next + save. Captions now run to the block's last `)`, with no change to the file + format, and existing entries parse as before. Photo lines without a blank + line between them or directly under a heading are recognised too, and a link + URL may hold one level of parentheses. + +- **Journal and essay text fields show Markdown, not stored HTML** + ([#789](https://github.com/ralksta/immich-folio/pull/789)). After a reload, + text blocks, quotes, captions and fact values showed raw `` and + `` tags, and text typed inside a tag could be lost on save. The fields + now show and take inline Markdown, the live preview renders bold, italics + and links while you type, and an untouched block saves byte for byte as + before. + +- **Contact messages from devices with a wrong clock are no longer dropped** + ([#771](https://github.com/ralksta/immich-folio/pull/771)). The fill-time + check compared the visitor's clock with the server's. A device ten minutes + fast, or a day slow, made a genuine message look like spam, which is + discarded silently while the sender sees the thank-you page. The form is now + stamped with the server's time. + +- **Missing pages answer a real 404** + ([#781](https://github.com/ralksta/immich-folio/pull/781), + [#772](https://github.com/ralksta/immich-folio/pull/772)). Since 0.18.0, a + switched-off `/contact`, `/impressum`, `/privacy` or `/map`, or an unknown + `/journal/`, could hang for about 30 seconds and fail with `500` when + the server was bound to `127.0.0.1`, because the proxy's rewrite looped back + through `localhost`. They now get `404` at once. Paths deeper than two + segments, such as `/travel/iceland/anything`, rendered the parent subpage or + album with `200` and its title; they answer `404` as well. + +- **_Email to photographer_ addresses the photographer** + ([#746](https://github.com/ralksta/immich-folio/pull/746) by + [@lancetm714](https://github.com/lancetm714), follow-up + [#747](https://github.com/ralksta/immich-folio/pull/747); reported in + [#736](https://github.com/ralksta/immich-folio/issues/736) by + [@RichKidsDev](https://github.com/RichKidsDev)). The proofing dialog opened a + mail with an empty _To:_ line. The recipient is now the footer contact + email, or the new optional `proofing.email` (Settings → General → _Proofing + email_); with neither set, the button is hidden. The address stays out of + the served HTML, as the footer's does. + +- **Proofing on public albums counts and filters correctly** + ([#763](https://github.com/ralksta/immich-folio/pull/763), + [#773](https://github.com/ralksta/immich-folio/pull/773), + [#787](https://github.com/ralksta/immich-folio/pull/787)). The selection + count included favourites stored under the same album key but not in the + grid — removed photos, or another album with the same name — so the bar + could read "3 selected" over one heart; only this album's photos count now. + With the favourites filter on, un-hearting the photo open in the lightbox + crashed the page when it was the last in the list, and un-hearting the last + favourite left an empty grid with no way back. The viewer now steps to the + previous favourite or closes, and an empty filter shows _Show all_. + +- **Hidden subpages stay off the map** + ([#782](https://github.com/ralksta/immich-folio/pull/782)). `hidden: true` + subpages are reachable by link only and were already left out of the + sitemap, but `/map` showed markers for albums only they carry — name, link + and thumbnail — and counted them in its header. Their photos still load for + anyone with the link. The map header also no longer counts an album on two + subpages twice. + +- **Edits made while an admin save is in flight are kept** + ([#768](https://github.com/ralksta/immich-folio/pull/768)). Editors marked + themselves clean when the save answered, so edits typed meanwhile showed as + saved, were missing from the draft and were lost on leaving; the journal + editor and the page builder even replaced them with the written copy. + Editors now compare with what they sent and stay unsaved when there is more + to save. This covers the journal and page editor, the page builder, + Settings, About and the privacy policy. + +- **Slug fields take hyphens and keep accented letters** + ([#751](https://github.com/ralksta/immich-folio/pull/751)). The new-page, + page-URL and new-journal-entry fields stripped a trailing hyphen on every + keystroke, so `about-us` could only be pasted. Accented letters became + hyphens (`Über mich` → `ber-mich`); they now fold to the base letter and `ß` + to `ss`, as subpage slugs already did. Existing slugs do not change. + +- **Journal dates show the written day everywhere** + ([#776](https://github.com/ralksta/immich-folio/pull/776)). On a server west + of Greenwich a date-only `2026-03-15` showed as 14 March on the index, + hand-typed dates showed "Invalid Date", and the entry page printed the raw + frontmatter. Index and entry now format dates the same way, in the site's + language and without a timezone shift; `YYYY-MM` shows month and year, + anything else is shown as written. Entries with the same date are ordered by + slug, so the index and previous/next links no longer depend on the + filesystem. + +- **Password gates and refusal pages** + ([#786](https://github.com/ralksta/immich-folio/pull/786)). Once the rate + limit kicked in, the gate said "wrong password" even for the right one and + cleared the field; it now says how many seconds to wait and keeps the input. + A locked journal entry has its own gate subtitle instead of the gallery's. + The ZIP refusal page uses the preset's colours in the site's colour mode + instead of plain white, and an expired proofing link is titled as expired + rather than "not found". + +- **The lightbox and error messages are readable in light mode** + ([#792](https://github.com/ralksta/immich-folio/pull/792)). The lightbox + overlay is dark in both modes, but its close button, arrows, counter and + focus ring used the page's near-black light-mode text colours, down to + 1.1:1. They now have their own tokens, the round controls get a dark + translucent fill that stays visible over a white sky, and the favourite + toggle keeps its colour on hover. A new per-mode `--error` colour brings + form and gate errors to 4.5:1, and text on accent-filled buttons in the + proofing dialogs and on Studio Modern's 404 page follows `--on-accent`. + +- **No sideways scrolling on phones and tablets** + ([#791](https://github.com/ralksta/immich-folio/pull/791), + [#797](https://github.com/ralksta/immich-folio/pull/797)). The footer's name + and legal links made every Studio Modern page 441px wide on a 390px phone, + which also pushed the lightbox's close and next buttons off-screen. The + footer wraps, the header switches to the menu whenever its links do not fit + in one row (not only below 641px), the map loses a second side gutter and + its grey bands, and Studio Modern's smaller phone and tablet gutters take + effect. Long hero, subpage, privacy and album titles wrap or hyphenate + instead of widening the page, and phone content no longer starts under the + header. + +- **The admin on phones, keyboards and screen readers** + ([#793](https://github.com/ralksta/immich-folio/pull/793), + [#794](https://github.com/ralksta/immich-folio/pull/794), + [#795](https://github.com/ralksta/immich-folio/pull/795)). On a phone the + subpage sheet was clipped after one screen, hiding its albums and _Delete_; + it now grows with its content and scrolls into view on tap, and the journal + list, status dropdown and Story Settings password field fit. The album + drawer, order editor, Story Settings and new-entry dialog are modal dialogs + with Escape and a focus trap, Escape closes only the innermost of two open + dialogs, and album picker rows are buttons reachable from the keyboard. An + unsaved privacy policy survives switching settings sections, like About. The + admin is marked `lang="en"`, so a screen reader on a German site no longer + reads it with a German voice, and the analytics chart shows the last 14 + calendar days, empty ones included. + +- **The admin overview no longer crashes on the older map form of + `subpages:`** in `gallery.yaml` + ([#754](https://github.com/ralksta/immich-folio/pull/754)). + +- **Translations** ([#784](https://github.com/ralksta/immich-folio/pull/784), + [#777](https://github.com/ralksta/immich-folio/pull/777)). Navigation, next + and back links and the subpage password gate name a subpage by its `title` + instead of its `gallery.yaml` key (`south-korea`). The map's zoom buttons + and Leaflet credit are translated, the share card no longer lowercases its + subtitle ("25 fotos"), German says "Diashow" throughout, French uses the + singular for 0, and the proofing mail subject no longer reads "1 items". + +### Internal + +- **The unit tests no longer write into the checkout's `content/`** + ([#778](https://github.com/ralksta/immich-folio/pull/778)). + `journal.test.ts` created and deleted a real journal entry there, which in a + self-hosted checkout briefly published it and left a + `test-journey-nordkap.md..deleted.bak` in + `content/journal/.backups/`; remove any such file by hand. A setup file now + fails every test that writes under `content/`. +- **Every public API route must declare whether the site password gates it** + ([#750](https://github.com/ralksta/immich-folio/pull/750)); a test fails on + a route that is in neither list. +- **The standalone build no longer traces the whole project** + ([#796](https://github.com/ralksta/immich-folio/pull/796)), which the config + memo of [#753](https://github.com/ralksta/immich-folio/pull/753) had caused + before release. +- Admin screenshots in the README and `docs/journal.md` show the redesigned + panel ([#735](https://github.com/ralksta/immich-folio/pull/735)). + +### Upgrade notes + +**Image URLs change once.** Each photo now has one URL per size tier, and the +`q` parameter is gone +([#762](https://github.com/ralksta/immich-folio/pull/762)). Browsers and a CDN +fetch every photo once more after the upgrade, as after an +`IMAGE_CACHE_VERSION` bump. No `IMAGE_CACHE_VERSION` bump is needed this time. + +**At most two ZIP downloads run at once per client IP** +([#774](https://github.com/ralksta/immich-folio/pull/774)). Behind a reverse +proxy, `TRUSTED_PROXY_HOPS` must be set correctly; otherwise every visitor has +the proxy's address and all of them share the same two slots. + +**Admin saves no longer clear the Immich album cache** +([#790](https://github.com/ralksta/immich-folio/pull/790)). Changes to +`gallery.yaml` and settings show on the next request as before. Changes made +inside Immich — new photos in an album, a renamed album — appear after +`CACHE_TTL`, after _Clear cache_ under Diagnostics, or at once when the Immich +webhook is set up. + +**Every password attempt costs one scrypt verification** (about 40 ms), +including attempts on keys that have no password +([#785](https://github.com/ralksta/immich-folio/pull/785)). The auth rate +limit keeps the total small. + +**The admin's default accent is blue** +([#795](https://github.com/ralksta/immich-folio/pull/795)); warnings stay +amber. Studio Modern keeps its red accent. + +**Paths deeper than two segments answer `404`** +([#781](https://github.com/ralksta/immich-folio/pull/781)), where they used to +render the parent subpage or album. Links of that shape were never generated +by the site. + +**Header and hero navigation share one order** +([#741](https://github.com/ralksta/immich-folio/pull/741)): external +`navLinks` from `settings.yaml` now come after About and Map. + +**`content/` gains `proofing.json` and `pages/`**, both created on demand, so +a writable `content/` is enough, as before. `/proof` is a fixed route now: a +subpage slugged `proof` is hidden behind it. + +**New, optional settings:** `proofing.email` and the `PROOFING_WEBHOOK_URL` +environment variable. See `content/settings.yaml.example`, +`.env.local.example` and `docs/gallery-config.md#client-proofing-links`. + ## [0.18.1] — 2026-09-27 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6db8697..85f48df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,12 @@ cheaper to test and stays tested when the markup around it changes. Reach for a component test when the behaviour _is_ the rendering: a state that hides a form, a control that must stay disabled, a keyboard shortcut. +**Never write into the checkout's `content/`.** On a self-hosted install that +directory is the live site. Services resolve it from `process.cwd()`, so a test +that saves or deletes anything points `cwd` at a temp directory first and +re-imports the service — `lib/__tests__/journal-backup.test.ts` shows how. +`vitest.content-guard.ts` fails any test that writes there anyway. + ## Code style - **TypeScript** — strict mode, no `any` without justification diff --git a/README.md b/README.md index 528bfd3..4817c60 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **secure reverse proxy** between your visitors and your private Immich instance: your Immich server stays on your local network, completely invisible to the outside world, while your albums are published as a gallery you control. -**Latest: v0.18.1** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new) +**Latest: v0.19.0** — private proofing links for clients, custom content pages and a round of security fixes. → [What's New](#whats-new) ## Contents @@ -67,6 +67,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a ** - **Subpage on/off toggle** — take a page offline without deleting it - **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile - **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side +- **Client proofing links** — a private link per client and album, picks saved server-side, a submit that locks the selection and pings you via webhook, expiry dates, download limits, and a Lightroom-ready export of the chosen file names ([guide](docs/gallery-config.md#client-proofing-links)) - **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals - **Lightbox watermark** — configurable overlay on fullscreen images - **Six interface languages** — English, German, French, Spanish, Italian and Dutch for everything visitors see @@ -225,6 +226,7 @@ ADMIN_PASSWORD=your-secure-password # enables /admin panel WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation CDN_URL=https://cdn.example.com # serve photos and videos through a pull CDN CONTACT_NOTIFY_URL=https://ntfy.sh/x # push for new contact form messages, overrides settings.yaml +PROOFING_WEBHOOK_URL=https://… # notify on submitted client proofing selections ``` > Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting). @@ -330,11 +332,11 @@ own password, separate from any album passwords, and writes straight to

Admin panel login screen - Visual page builder with hero images, standalone albums and subpages + Page builder: the site structure on the left, the selected page with its albums on the right

Album picker listing shared Immich albums with photo counts - Settings editor with site identity and feature toggles + Settings editor: sections on the left, site identity and feature switches grouped by meaning

Login · page builder · album picker · settings editor

@@ -342,15 +344,15 @@ own password, separate from any album passwords, and writes straight to ## What's New -v0.18.0 — a contact form, a privacy page and a reworked admin: +v0.19.0 — client proofing links, content pages and a big quality pass: -- **Built-in contact form** — no SMTP or form service: messages land in a new admin inbox, with an optional ntfy push that carries nothing about the sender -- **Privacy page and fewer third parties** — `/privacy` with a list of what your installation actually processes; fonts and map styles are served from your own server, and email addresses stay out of the page source -- **A reworked admin panel** — a collapsible sidebar, an overview page, the page builder as a structure list with Undo, and ON/OFF switches grouped by meaning -- **Four more languages and a CDN mode** — French, Spanish, Italian and Dutch, and `CDN_URL` to serve photos through a pull CDN -- **Two security fixes** — draft and password-protected journal entries used as subpage essays, and unescaped Immich descriptions ([advisories](https://github.com/ralksta/immich-folio/security/advisories)) +- **Private proofing links for clients** — one link per client and album, with an optional expiry, download limit and ZIP; the selection is saved on the server, the client submits it, and you export the picks as a Lightroom or Capture One filter +- **Custom content pages** — `content/pages/.md` pages (pricing, workshops, anything) written in the journal editor and placed in the menu next to your subpages +- **A calmer admin** — library search and multi-select in the photo picker, a warning instead of a silent overwrite when a file changed elsewhere, settings from the environment shown as locked, and invalid values refused on save +- **Better on phones and in light mode** — no sideways scrolling, a menu whenever the navigation does not fit, readable lightbox controls, and an accessibility baseline (reduced motion, focus handling, touch targets) +- **Security fixes** — media URLs stop working once a photo leaves the site, offline subpages are unpublished everywhere, and the sitemap skips protected journal entries ([advisories](https://github.com/ralksta/immich-folio/security/advisories)) -**Upgrade note:** a custom CSP or proxy allowlist must now allow `tile.openstreetmap.org` for map tiles, and `theme.accent` must be a hex colour. Nothing else to migrate. +**Upgrade note:** image URLs change once (browser and CDN caches refill by themselves), at most two ZIP downloads run at once per visitor — set `TRUSTED_PROXY_HOPS` behind a reverse proxy — and changes made inside Immich now show after `CACHE_TTL` or _Clear cache_. Details in the [changelog](CHANGELOG.md). Security fixes ship in normal releases, so **running the latest release is the recommended baseline**. @@ -381,12 +383,14 @@ easier to live with. picked, as one archive of the originals — streamed, so even a large shoot arrives complete. In v0.18.0, kept accent-coloured buttons readable when the accent is light, and made a mistyped accent fall back to the theme instead of - turning every button invisible. + turning every button invisible. In v0.19.0, gave the proofing dialog's + _Email to photographer_ an actual recipient. - **[@RichKidsDev](https://github.com/RichKidsDev)** — noticed that the Impressum still cited the TMG, which the DDG replaced in 2024. That report turned into a proper overhaul in v0.18.0: the right law by default, a heading you can change for other countries, clickable contacts and a contact form as - a second channel. + a second channel. In v0.19.0, reported that _Email to photographer_ opened a + mail with nobody in the _To:_ line. - **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait photos rendering as landscape tiles, because the grid ignored the camera's EXIF orientation flag. That brought the masonry layout's stagger back for diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index 15d7c5e..cd17f1a 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -11,14 +11,16 @@ import { albumMetaDetail } from '@/lib/albumMeta'; import { AlbumNav } from '@/components/AlbumNav'; import { StructuredData } from '@/components/StructuredData'; import type { AlbumNavPair } from '@/lib/albumNav'; +import type { ProofSessionInit } from '@/components/ProofingContext'; interface AlbumDetailViewProps { album: ImmichAlbum; images: PhotoItem[]; layout: GridConfig['layout']; gridStyle: React.CSSProperties; - backLinkHref: string; - backLinkLabel: string; + /** Omitted on a client proofing link, which has nowhere to go back to. */ + backLinkHref?: string; + backLinkLabel?: string; subtitle?: string; heroImageUrl?: string; heroBlurDataURL?: string; @@ -31,6 +33,12 @@ interface AlbumDetailViewProps { proofing?: boolean; /** Offer the "send by email" button in the proofing modal. */ allowMailto?: boolean; + /** + * `encodeEmail()` of where the proofing dialog's "email to photographer" + * goes, when one is configured (#736). Encoded, like every address the site + * renders, so the plain form stays out of the HTML and the RSC payload. + */ + encodedMailto?: string; /** * The ZIP endpoint for this album, when it offers downloads (#475). Renders a * "download album" link in the header and lets the proofing modal offer a @@ -41,6 +49,10 @@ interface AlbumDetailViewProps { nav?: AlbumNavPair; /** JSON-LD for this album, or null when no site URL is configured (#472). */ structuredData?: Record | null; + /** A client proofing link's session; see PhotoGrid. */ + proofSession?: ProofSessionInit; + /** Rendered under the title — the proofing link's greeting and notes. */ + intro?: React.ReactNode; } export function AlbumDetailView({ @@ -58,9 +70,12 @@ export function AlbumDetailView({ showGear = true, proofing, allowMailto, + encodedMailto, downloadArchiveUrl, nav, structuredData, + proofSession, + intro, }: AlbumDetailViewProps) { const metaDetail = albumMetaDetail(album, showGear); const t = getServerDictionary(); @@ -86,7 +101,7 @@ export function AlbumDetailView({ )}
- + {backLinkHref && }

{album.albumName}

{subtitle && (

@@ -94,6 +109,7 @@ export function AlbumDetailView({

)} {album.description &&

{album.description}

} + {intro} {downloadArchiveUrl && (
{nav && } diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx index 5a3ec6a..4734ff2 100644 --- a/app/[...path]/EssayView.tsx +++ b/app/[...path]/EssayView.tsx @@ -2,17 +2,32 @@ import { useState, useMemo } from 'react'; import Image from 'next/image'; +import dynamic from 'next/dynamic'; import { Lightbox, type LightboxWatermark } from '@/components/Lightbox'; -import { ProofingProvider, useProofing } from '@/components/ProofingContext'; -import { ProofingModal } from '@/components/ProofingModal'; +import { useProofing } from '@/components/useProofing'; +import { ProofingProvider, ProofingModal } from '@/components/ProofingLazy'; import { FadeIn } from '@/components/FadeIn'; -import { LeafletMap, escapeHtml } from '@/components/LeafletMap'; +import { escapeHtml } from '@/lib/escapeHtml'; import type { ParsedEssay, EssayBlock } from '@/lib/essay'; import { renderInlineMarkdown } from '@/lib/essay'; import { essayPhotoSequence } from '@/lib/essaySequence'; import type { PhotoItem } from './PhotoGrid'; import './essay.css'; import { useDictionary } from '@/components/I18nProvider'; +import { formatJournalDate } from '@/lib/journalDate'; + +/** + * The map block is the only thing in an essay that needs Leaflet, and most + * essays have none. Imported statically, LeafletMap put its wrapper and + * Leaflet's stylesheet (render-blocking CSS) on every album, subpage and + * journal page. The map only ever drew on the client — Leaflet itself was + * already imported inside an effect — so the server renders the same empty + * container it rendered before, and the wrapper arrives with the map. + */ +const LeafletMap = dynamic(() => import('@/components/LeafletMap').then((m) => m.LeafletMap), { + ssr: false, + loading: () =>
, +}); interface EssayViewProps { essay: ParsedEssay; @@ -28,6 +43,8 @@ interface EssayViewProps { proofing?: boolean; /** Offer the "send by email" button in the proofing modal. */ allowMailto?: boolean; + /** `encodeEmail()` of the proofing recipient, when one is configured (#736). */ + encodedMailto?: string; } function EssayViewContent({ @@ -389,6 +406,9 @@ function EssayViewContent({ } }; + // The story's photos that are selected, not the whole stored set (see PhotoGrid). + const selectedCount = proofing ? proofing.getSelectedTokens().length : 0; + return (
@@ -396,7 +416,9 @@ function EssayViewContent({ {displaySubtitle &&

{displaySubtitle}

} {(essay.frontmatter.author || essay.frontmatter.date) && (
- {[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')} + {[essay.frontmatter.author, formatJournalDate(essay.frontmatter.date, t.dateLocale)] + .filter(Boolean) + .join(' • ')}
)}
@@ -429,9 +451,9 @@ function EssayViewContent({ )} -
{essay.blocks.map(renderBlock)}
+
{essay.blocks.map(renderBlock)}
- {proofing && proofing.favorites.size > 0 && ( + {proofing && selectedCount > 0 && (
- {t.proofing.selected(proofing.favorites.size)} + {t.proofing.selected(selectedCount)} +
+
+ ) : ( +
+ {gridItems} +
+ )} - {proofing && proofing.favorites.size > 0 && ( + {/* Kept while the filter is on: un-hearting the last favourite there + empties the grid, and the bar holds the only way back to all photos. */} + {proofing && !proofing.session && (selectedCount > 0 || proofing.isFilterActive) && (
- {proofing.isFilterActive - ? t.proofing.showAll - : t.proofing.selected(proofing.favorites.size)} + {proofing.isFilterActive ? t.proofing.showAll : t.proofing.selected(selectedCount)}
@@ -302,8 +277,8 @@ export default async function HomePage() {
@@ -319,15 +294,15 @@ export default async function HomePage() { {/* ── Right Panel (Hero Carousel) ─────────────── */}
- +
); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 14f6125..de00c7b 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -37,7 +37,7 @@ export default function PrivacyPage() { {/* Every html string below comes from renderInlineMarkdown, which escapes the author's text before adding its own tags. */} -
+
{blocks.map((block, i) => { if (block.type === 'heading') { const Tag = block.level === 2 ? 'h2' : 'h3'; @@ -55,7 +55,7 @@ export default function PrivacyPage() { } return

; })} -

+ ); } diff --git a/app/privacy/privacy.css b/app/privacy/privacy.css index b96c7e8..737900d 100644 --- a/app/privacy/privacy.css +++ b/app/privacy/privacy.css @@ -10,6 +10,10 @@ font-family: var(--font-serif); font-size: clamp(2.2rem, 7vw, 3.5rem); font-weight: 400; + /* "Datenschutzerklärung" is one word wider than a phone's content column + at this size; unbroken it made the page scroll sideways. */ + hyphens: auto; + overflow-wrap: break-word; } .privacy-page__content { max-width: 680px; diff --git a/app/proof/[token]/page.tsx b/app/proof/[token]/page.tsx new file mode 100644 index 0000000..108c5c1 --- /dev/null +++ b/app/proof/[token]/page.tsx @@ -0,0 +1,121 @@ +/** + * A client proofing link: /proof/. + * + * Renders the session's album with the proofing controls in session mode — + * hearts save to the server, and the client submits the selection when done. + * The album does not have to be published; see immich.getProofingAlbum(). + * + * Kept out of search engines and the sitemap: the URL is the credential. + */ + +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { immich } from '@/lib/immich'; +import { getConfig, hasExifPanelContent } from '@/lib/config'; +import { encodeAssetId } from '@/lib/tokens'; +import { getServerDictionary } from '@/lib/i18n/server'; +import { downloadsRemaining, findSessionByToken, isExpired } from '@/lib/proofSessions'; +import { AlbumDetailView } from '../../[...path]/AlbumDetailView'; +import { toPhotoItems } from '../../[...path]/photoItems'; + +export const dynamic = 'force-dynamic'; + +interface ProofPageProps { + params: Promise<{ token: string }>; +} + +export async function generateMetadata({ params }: ProofPageProps): Promise { + const { token } = await params; + const session = await findSessionByToken(token); + const t = getServerDictionary(); + return { + // An expired link names nobody, not even in the tab title — but the tab + // says the same as the page, rather than "not found" over "expired". + title: !session + ? t.error.notFoundTitle + : isExpired(session) + ? t.proofSession.expiredTitle + : t.proofSession.greeting(session.clientName), + robots: { index: false, follow: false }, + // Never let the link travel as a Referer, not even to our own font host. + referrer: 'no-referrer', + }; +} + +export default async function ProofPage({ params }: ProofPageProps) { + const { token } = await params; + const session = await findSessionByToken(token); + if (!session) notFound(); + + const t = getServerDictionary(); + + if (isExpired(session)) { + return ( +
+

{t.proofSession.expiredTitle}

+

{t.proofSession.expiredText}

+
+ ); + } + + const album = await immich.getProofingAlbum(session.albumId); + if (!album) notFound(); + + const config = getConfig(); + const images = toPhotoItems( + album.assets, + config.exif.onHover && config.exif.camera, + config.exif.caption, + ); + + // Only what is still in the album: a photo removed in Immich after it was + // picked drops out of the selection instead of haunting the counter. + const inAlbum = new Set(album.assets.map((a) => a.id)); + const selected = session.selection.filter((id) => inAlbum.has(id)).map(encodeAssetId); + + const validUntil = session.expiresOn + ? new Date(`${session.expiresOn}T12:00:00`).toLocaleDateString(t.dateLocale, { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : null; + + const gridStyle = { + '--grid-columns': config.grid.columns, + ...(config.gridGapExplicit ? { '--grid-gap': `${config.grid.gap}px` } : {}), + '--grid-aspect-ratio': config.grid.aspectRatio, + } as React.CSSProperties; + + return ( + +

+ {t.proofSession.greeting(session.clientName)} +

+

+ {session.submittedAt ? t.proofSession.locked : t.proofSession.intro} +

+ {validUntil && ( +

{t.proofSession.validUntil(validUntil)}

+ )} + + } + /> + ); +} diff --git a/app/proof/__tests__/metadata.test.ts b/app/proof/__tests__/metadata.test.ts new file mode 100644 index 0000000..18e8fa0 --- /dev/null +++ b/app/proof/__tests__/metadata.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * An expired proofing link rendered "This link has expired" under a tab that + * said "Not found". The tab still names nobody, but now agrees with the page. + */ + +vi.mock('@/lib/proofSessions', async (importOriginal) => ({ + ...(await importOriginal()), + findSessionByToken: vi.fn(), +})); +vi.mock('@/lib/immich', () => ({ immich: {} })); +vi.mock('@/lib/config', () => ({ + getConfig: vi.fn(), + getConfigOrNull: vi.fn(() => null), + hasExifPanelContent: vi.fn(), +})); + +import { generateMetadata } from '../[token]/page'; +import { findSessionByToken } from '@/lib/proofSessions'; + +const mockFind = findSessionByToken as unknown as ReturnType; +const params = Promise.resolve({ token: 'tok' }); + +beforeEach(() => mockFind.mockReset()); + +describe('proofing link tab title', () => { + it('says an expired link has expired, and names nobody', async () => { + mockFind.mockResolvedValue({ clientName: 'Anna', expiresOn: '2000-01-01', selection: [] }); + const meta = await generateMetadata({ params }); + expect(meta.title).toBe('This link has expired'); + expect(String(meta.title)).not.toContain('Anna'); + }); + + it('greets the client on a live link', async () => { + mockFind.mockResolvedValue({ clientName: 'Anna', expiresOn: null, selection: [] }); + expect((await generateMetadata({ params })).title).toBe('Selection for Anna'); + }); + + it('says not found for a link that never existed', async () => { + mockFind.mockResolvedValue(null); + expect((await generateMetadata({ params })).title).toBe('Not found'); + }); +}); diff --git a/app/themes/editorial.css b/app/themes/editorial.css index 0c15740..f530cba 100644 --- a/app/themes/editorial.css +++ b/app/themes/editorial.css @@ -138,7 +138,10 @@ [data-preset='editorial'] .album-header__title, [data-preset='editorial'] .subpage-title { font-family: var(--font-serif); - font-size: 5rem; + /* A fixed 5rem ran "Deutschland" 80px past a 390px phone and made the + whole page scroll sideways. */ + font-size: clamp(2.5rem, 11vw, 5rem); + overflow-wrap: break-word; font-weight: 400; letter-spacing: -0.02em; } diff --git a/app/themes/monograph.css b/app/themes/monograph.css index 419beb4..53fb2f5 100644 --- a/app/themes/monograph.css +++ b/app/themes/monograph.css @@ -85,11 +85,21 @@ transform 0.4s ease; } -[data-preset='monograph'] .photo-grid__item:hover .photo-grid__item-exif { +[data-preset='monograph'] .photo-grid__item:hover .photo-grid__item-exif, +[data-preset='monograph'] + .photo-grid__item:is(:focus-visible, :has(:focus-visible)) + .photo-grid__item-exif { opacity: 1; transform: translateY(0); } +@media (hover: none) { + [data-preset='monograph'] .photo-grid__item .photo-grid__item-exif { + opacity: 1; + transform: translateY(0); + } +} + /* Album cards: minimal text below */ [data-preset='monograph'] .album-card { border: 1px solid var(--border-subtle); diff --git a/app/themes/studio-modern.css b/app/themes/studio-modern.css index d8eec2a..20725d8 100644 --- a/app/themes/studio-modern.css +++ b/app/themes/studio-modern.css @@ -877,7 +877,6 @@ font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; - color: #fff; } [data-preset='studio-modern'] .scroll-to-top { @@ -960,8 +959,11 @@ } /* ── Map ────────────────────────────────────────────────────── */ +/* No side padding: .main already holds the page to --sm-gutter, and adding it + again here put the map page at twice the gutter of every other page (and + left a phone a map about 200px wide). */ [data-preset='studio-modern'] .map-page__header { - padding: 64px var(--sm-gutter) 32px; + padding: 64px 0 32px; display: flex; align-items: flex-end; justify-content: space-between; @@ -1021,7 +1023,7 @@ } [data-preset='studio-modern'] .map-container { - margin: 0 var(--sm-gutter) var(--sm-gutter); + margin: 0 0 var(--sm-gutter); border: var(--sm-hairline); border-radius: 0; } @@ -1120,8 +1122,12 @@ } /* ── Responsive ─────────────────────────────────────────────── */ +/* `html` in front: app/tokens.css sets the 48px default with the same + [data-preset] selector and is imported after this file, so without the + extra weight neither narrower gutter ever applied — phones kept 48px a + side, and the map page was left about 200px wide. */ @media (max-width: 1024px) { - [data-preset='studio-modern'] { + html[data-preset='studio-modern'] { --sm-gutter: 32px; } @@ -1135,12 +1141,15 @@ } @media (max-width: 640px) { - [data-preset='studio-modern'] { + html[data-preset='studio-modern'] { --sm-gutter: 20px; } + /* The header stays --header-height (64px) tall on a phone, so a flat 56px + tucked the album back link under it. Same 24px breathing room as the + 88px on desktop. */ [data-preset='studio-modern'] .main { - padding: 56px var(--sm-gutter) 80px; + padding: calc(var(--header-height) + 24px) var(--sm-gutter) 80px; } [data-preset='studio-modern'] .header { diff --git a/app/tokens.css b/app/tokens.css index c631c4c..d078d9f 100644 --- a/app/tokens.css +++ b/app/tokens.css @@ -37,6 +37,31 @@ --accent-dim: color-mix(in srgb, var(--accent) 12%, transparent); --on-accent: var(--on-accent-dark, #ffffff); + /* Error and destructive text. One value per mode: no single red clears + 4.5:1 on both a near-black and a near-white page (the fixed reds these + replace measured 3.2–3.8:1 on the light presets). #ff6b6b is at least + 5.6:1 on every dark surface token, #c62828 (below) at least 4.66:1 on + every light one. */ + --error: #ff6b6b; + + /* The lightbox paints its own dark overlay in both modes, so its controls + cannot use --text-*: in light mode those are near-black, and the close + button, arrows and counter came out at 1.1–2:1. Lightbox.module.css + points the text tokens at these inside the overlay in light mode; dark + mode keeps each preset's tinted greys. */ + --lightbox-text: rgba(255, 255, 255, 0.92); + --lightbox-text-secondary: rgba(255, 255, 255, 0.72); + --lightbox-text-muted: rgba(255, 255, 255, 0.62); + --lightbox-border: rgba(255, 255, 255, 0.12); + /* Round controls float over the photo on narrow screens; a dark fill keeps + them visible on a white sky, where the old white-alpha disc vanished. */ + --lightbox-control-bg: rgba(0, 0, 0, 0.35); + --lightbox-control-bg-hover: rgba(0, 0, 0, 0.55); + --lightbox-control-border: rgba(255, 255, 255, 0.18); + /* A saved favourite. Not the accent: minimal's is white and a custom one + may be dark navy, neither of which marks anything on the overlay. */ + --lightbox-fav: #ff6b6b; + --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif; --font-caption: 'EB Garamond', Georgia, serif; @@ -71,6 +96,8 @@ --accent: var(--accent-light, #c8000f); --accent-dim: color-mix(in srgb, var(--accent) 12%, transparent); --on-accent: var(--on-accent-light, #ffffff); + + --error: #c62828; } /* ── Studio Modern: Leica Precision Palette ──────── */ @@ -314,10 +341,17 @@ img { max-width: 100%; } +/* [tabindex='-1'] is left out: those are focus targets for scripts and skip + links (
, the lightbox dialog), and outlining a whole region there + helps nobody. */ a:focus-visible, button:focus-visible, input:focus-visible, -[role='button']:focus-visible { +textarea:focus-visible, +select:focus-visible, +summary:focus-visible, +[role='button']:focus-visible, +[tabindex]:not([tabindex='-1']):focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; } diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index 6938006..4f27d96 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -7,6 +7,12 @@ * Spam protection without a captcha, which would be another third party: a * honeypot field people never see, and `startedAt`, the time the form was * rendered, which the server compares against the submission. + * + * `startedAt` is the server's clock, handed down by the page. The check + * subtracts it from the server's clock at submission, and a stamp from the + * visitor's device made that a comparison between two clocks: a phone running + * a few minutes fast looked like a bot posting before the form existed, and + * its message was dropped behind a thank-you page. */ import { useState } from 'react'; @@ -14,15 +20,14 @@ import { useDictionary } from './I18nProvider'; interface Props { retentionDays: number; + /** `Date.now()` on the server when the page was rendered. */ + renderedAt: number; } type State = 'idle' | 'sending' | 'done'; -export default function ContactForm({ retentionDays }: Props) { +export default function ContactForm({ retentionDays, renderedAt }: Props) { const t = useDictionary().contact; - // When the form became usable. A bot posting straight to the endpoint has - // none; one filling the form on load is faster than MIN_FILL_MS. - const [startedAt] = useState(() => Date.now()); const [state, setState] = useState('idle'); const [error, setError] = useState(''); @@ -40,7 +45,9 @@ export default function ContactForm({ retentionDays }: Props) { email: data.get('email'), message: data.get('message'), website: data.get('website'), - startedAt, + // A bot posting straight to the endpoint has no stamp; one filling + // the form on load is faster than MIN_FILL_MS. + startedAt: renderedAt, }), }); if (res.ok) { diff --git a/components/HeroCarousel.tsx b/components/HeroCarousel.tsx index 04e76df..cdf1671 100644 --- a/components/HeroCarousel.tsx +++ b/components/HeroCarousel.tsx @@ -2,13 +2,17 @@ * HeroCarousel — crossfade between hero images on a timer. * * If only one image is provided, renders a static image with no timer. - * When multiple images are provided, crossfades between them every 6 seconds. + * When multiple images are provided, crossfades between them every 6 seconds + * — unless the visitor prefers reduced motion, or has paused it with the + * pause control (WCAG 2.2.2: anything that moves on its own for more than + * five seconds needs a way to stop it, #696). */ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useSyncExternalStore } from 'react'; import Image from 'next/image'; +import { useDictionary } from './I18nProvider'; interface HeroImage { src: string; @@ -19,23 +23,51 @@ interface HeroImage { interface HeroCarouselProps { images: HeroImage[]; + /** + * `sizes` for next/image. Most heroes are full-width; only the split hero's + * image panel is half the viewport, and only above the mobile breakpoint. + */ + sizes?: string; } -const INTERVAL_MS = 6000; +export const INTERVAL_MS = 6000; -export function HeroCarousel({ images }: HeroCarouselProps) { +const REDUCED_MOTION = '(prefers-reduced-motion: reduce)'; + +function subscribeReducedMotion(onChange: () => void) { + const mql = window.matchMedia(REDUCED_MOTION); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); +} + +/** True when the visitor asked the OS for less motion. False during SSR. */ +function usePrefersReducedMotion(): boolean { + return useSyncExternalStore( + subscribeReducedMotion, + () => window.matchMedia(REDUCED_MOTION).matches, + () => false, + ); +} + +export function HeroCarousel({ images, sizes = '100vw' }: HeroCarouselProps) { + const t = useDictionary(); const [currentIndex, setCurrentIndex] = useState(0); + const reducedMotion = usePrefersReducedMotion(); + // null = the visitor has not touched the control; the default then follows + // the motion preference. An explicit play overrides reduced motion. + const [pausedByUser, setPausedByUser] = useState(null); + const paused = pausedByUser ?? reducedMotion; const advance = useCallback(() => { setCurrentIndex((prev) => (prev + 1) % images.length); }, [images.length]); useEffect(() => { - if (images.length <= 1) return; + if (images.length <= 1 || paused) return; const timer = setInterval(advance, INTERVAL_MS); return () => clearInterval(timer); - }, [images.length, advance]); + }, [images.length, advance, paused]); if (images.length === 0) { return
; @@ -50,7 +82,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) { alt="" className="hero__image" fill - sizes="50vw" + sizes={sizes} priority {...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } @@ -70,7 +102,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) { alt="" className={`hero__carousel-image${i === currentIndex ? ' hero__carousel-image--active' : ''}`} fill - sizes="50vw" + sizes={sizes} priority={i === 0} {...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } @@ -78,6 +110,25 @@ export function HeroCarousel({ images }: HeroCarouselProps) { /> ))} + ); } diff --git a/components/LeafletMap.tsx b/components/LeafletMap.tsx index f88eaf2..767bd18 100644 --- a/components/LeafletMap.tsx +++ b/components/LeafletMap.tsx @@ -13,6 +13,8 @@ import { useEffect, useRef } from 'react'; import 'leaflet/dist/leaflet.css'; +import { useDictionary } from './I18nProvider'; +import { escapeHtml } from '@/lib/escapeHtml'; export interface LeafletMarker { lat: number; @@ -33,14 +35,14 @@ interface LeafletMapProps { onError?: (err: unknown) => void; } -/** Escapes special HTML characters for Leaflet's string-based popups and icons. */ -export function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); +export { escapeHtml } from '@/lib/escapeHtml'; + +/** + * Leaflet's attribution prefix with its English link title swapped for + * `title`. The rest — link, flag, name — is Leaflet's own markup, kept as is. + */ +export function localizedLeafletPrefix(prefix: string, title: string): string { + return prefix.replace(/title="[^"]*"/, `title="${escapeHtml(title)}"`); } export function LeafletMap({ @@ -53,6 +55,11 @@ export function LeafletMap({ }: LeafletMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); + // Read inside the effect through a ref: the locale never changes on a + // mounted page, and a new dictionary object must not rebuild the map. + const t = useDictionary(); + const labelsRef = useRef(t.map); + labelsRef.current = t.map; // Callers rebuild the markers array on every render; the map is only // rebuilt when its content changes. @@ -71,8 +78,17 @@ export function LeafletMap({ const L = (await import('leaflet')).default; if (cancelled || !containerRef.current || mapRef.current) return; - const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true }); + // The zoom buttons' titles (which Leaflet also uses as their aria-label) + // and the credit's link title are English in Leaflet; the site's + // dictionary supplies them instead. + const labels = labelsRef.current; + const map = L.map(containerRef.current, { zoomControl: false, attributionControl: true }); mapRef.current = map; + L.control.zoom({ zoomInTitle: labels.zoomIn, zoomOutTitle: labels.zoomOut }).addTo(map); + const prefix = map.attributionControl.options.prefix; + if (typeof prefix === 'string') { + map.attributionControl.setPrefix(localizedLeafletPrefix(prefix, labels.leafletTitle)); + } // OpenStreetMap's own tiles: free to use with attribution and a Referer, // which the Referrer-Policy sends. CARTO's Dark Matter, used before, diff --git a/components/Lightbox.module.css b/components/Lightbox.module.css index 17f5810..79d2605 100644 --- a/components/Lightbox.module.css +++ b/components/Lightbox.module.css @@ -11,6 +11,20 @@ animation: lightbox-fade-in var(--transition-normal) ease forwards; } +/* The overlay is dark in both modes — see "Studio-Inspired Lightbox Defaults" + below and the studio-modern block, which both paint it near-black. In light + mode the page's text tokens are near-black too, so every control that reads + them (close, arrows, counter, the bottom-bar buttons, and the focus ring in + app/tokens.css, which is var(--text-primary)) sat at 1.1–2:1. Inside the + overlay they are pointed at the lightbox tokens instead. Dark mode is left + alone: its tokens are already light, and tinted per preset. */ +:global([data-theme='light']) .overlay { + --text-primary: var(--lightbox-text); + --text-secondary: var(--lightbox-text-secondary); + --text-muted: var(--lightbox-text-muted); + --border-subtle: var(--lightbox-border); +} + @keyframes lightbox-fade-in { from { opacity: 0; @@ -109,10 +123,10 @@ top: 50%; transform: translateY(-50%); z-index: 1001; - background: rgba(255, 255, 255, 0.08); + background: var(--lightbox-control-bg); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid var(--lightbox-control-border); border-radius: 50%; width: 48px; height: 48px; @@ -127,7 +141,7 @@ } .nav:hover { - background: rgba(255, 255, 255, 0.15); + background: var(--lightbox-control-bg-hover); color: var(--text-primary); } @@ -203,10 +217,10 @@ align-items: center; gap: 6px; z-index: 1001; - background: rgba(255, 255, 255, 0.08); + background: var(--lightbox-control-bg); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid var(--lightbox-control-border); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); @@ -219,10 +233,22 @@ } .infoToggle:hover { - background: rgba(255, 255, 255, 0.15); + background: var(--lightbox-control-bg-hover); color: var(--text-primary); } +/* A saved favourite. Set by class rather than inline, so a preset's own + colour for the toggle (studio-modern's) still applies to the unsaved state — + the inline `inherit` it used to fall back to picked up the page's near-black + text in light mode (1.08:1). The extra class outranks that preset block, + which comes later in this file, hover included: studio-modern hovers in + the accent, which a dark custom accent drops to about 1.3:1. */ +.bottomBar .infoToggle.favActive, +.bottomBar .infoToggle.favActive:hover { + color: var(--lightbox-fav); + font-weight: 600; +} + /* Anchors are not buttons; strip the underline so it matches its neighbours. */ .downloadToggle { text-decoration: none; diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx index c6eedaf..e140c6d 100644 --- a/components/Lightbox.tsx +++ b/components/Lightbox.tsx @@ -19,7 +19,7 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid'; import { useExif } from '@/hooks/useExif'; import { useSwipe } from '@/hooks/useSwipe'; import styles from './Lightbox.module.css'; -import { useProofing } from './ProofingContext'; +import { useProofing } from './useProofing'; import { IconHeart } from './Icons'; import { useDictionary } from './I18nProvider'; // From lib/config/schema directly: lib/config/index.ts pulls in `fs`, which a @@ -27,6 +27,8 @@ import { useDictionary } from './I18nProvider'; import { resolveWatermarkOpacity } from '@/lib/config/schema'; import { formatCamera } from '@/lib/exif'; import { buildPhotoPermalink } from '@/lib/photoHash'; +import { trapTabKey } from '@/lib/focusTrap'; +import { canonicalImageUrl } from '@/lib/imageSize'; import { nextSlideshowSpeed, type SlideshowSpeed } from '@/lib/slideshow'; import { LIGHTBOX_SHORTCUTS, @@ -105,6 +107,34 @@ export function Lightbox({ } }, [mounted]); + /* + * Focus stays inside the dialog while it is open, and goes back to the grid + * when it closes (#696) — to the tile of the photo that was on screen last, + * not necessarily the one that opened the viewer, so a keyboard visitor who + * browsed ahead continues from where they are. Tiles mark themselves with + * `data-lightbox-index` inside a `data-lightbox-group`; without them the + * opener gets focus back. + */ + const indexRef = useRef(currentIndex); + useEffect(() => { + indexRef.current = currentIndex; + }, [currentIndex]); + + useEffect(() => { + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const onTab = (e: KeyboardEvent) => { + if (overlayRef.current) trapTabKey(overlayRef.current, e); + }; + document.addEventListener('keydown', onTab); + return () => { + document.removeEventListener('keydown', onTab); + const group = opener?.closest('[data-lightbox-group]'); + const tile = group?.querySelector(`[data-lightbox-index="${indexRef.current}"]`); + const target = tile ?? opener; + if (target?.isConnected) target.focus(); + }; + }, []); + // Reset EXIF data when switching images; refetch if panel is open useEffect(() => { clearExif(); @@ -260,7 +290,7 @@ export function Lightbox({ const preload = (index: number) => { if (index >= 0 && index < assets.length && assets[index].type !== 'video') { const img = new Image(); - img.src = assets[index].previewUrl; + img.src = canonicalImageUrl(assets[index].previewUrl); } }; preload(currentIndex + 1); @@ -487,7 +517,7 @@ export function Lightbox({ // eslint-disable-next-line @next/next/no-img-element {current.caption setImageLoaded(true)} @@ -609,9 +639,7 @@ export function Lightbox({ {/* Counter */}
- - Photo {currentIndex + 1} of {assets.length} - + {t.lightbox.position(currentIndex + 1, assets.length)} @@ -621,11 +649,7 @@ export function Lightbox({ {/* Proofing favorite button */} {proofing && current && ( + )} + + {session.submitted ? p.locked : status} + + +
+ + {open && ( +
+
e.stopPropagation()} + style={{ + background: 'var(--bg-card, #1e1e1e)', + color: 'var(--text-primary, #ffffff)', + borderRadius: 'var(--radius-md, 12px)', + border: '1px solid var(--border-subtle, rgba(255,255,255,0.1))', + padding: '1.5rem', + maxWidth: '480px', + width: '100%', + boxShadow: '0 20px 40px rgba(0,0,0,0.5)', + }} + > +
+

+ {p.modalTitle(count)} +

+ +
+ +

+ {session.submitted ? p.submitted : count === 0 ? p.empty : p.confirmSubmit(count)} +

+ +
+ {!session.submitted && ( + + )} + + {canDownload && count > 0 && selectionSaved && ( + + {p.downloadSelection} + + )} + {canDownload && session.download === 'album' && ( + + {p.downloadAll} + + )} + {session.download !== 'none' && downloadsLeft !== null && ( +

+ {downloadsLeft > 0 ? p.downloadsLeft(downloadsLeft) : t.download.limitReached} +

+ )} + {session.saveState === 'error' && ( +

+ {p.saveFailed} +

+ )} +
+
+
+ )} + + ); +} diff --git a/components/ProofingContext.tsx b/components/ProofingContext.tsx index a9160e0..2a5738a 100644 --- a/components/ProofingContext.tsx +++ b/components/ProofingContext.tsx @@ -1,10 +1,52 @@ 'use client'; -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing'; import { useDictionary } from './I18nProvider'; +import { ProofingContext } from './useProofing'; -interface ProofingContextType { +export { useProofing } from './useProofing'; + +/** What the server tells a client proofing page about its session. */ +export interface ProofSessionInit { + /** The link token — the capability for this session's API routes. */ + token: string; + /** Asset tokens already selected, from the server. */ + selected: string[]; + submitted: boolean; + download: 'none' | 'selection' | 'album'; + /** ZIP downloads left, or null for unlimited. */ + downloadsRemaining: number | null; +} + +export type ProofSaveState = 'idle' | 'saving' | 'saved' | 'error'; + +/** + * Session mode: a client proofing link (/proof/). The selection lives on + * the server instead of localStorage and the URL, and can be submitted once. + */ +export interface ProofSessionState { + saveState: ProofSaveState; + /** + * Hearts the server has not confirmed yet — including those still waiting + * out the autosave debounce, while `saveState` reads 'idle' or 'saved'. + */ + unsaved: boolean; + submitted: boolean; + /** Save anything pending, then lock the selection. Resolves to success. */ + submit: () => Promise; + download: ProofSessionInit['download']; + downloadsRemaining: number | null; + /** Count a download that was just started against `downloadsRemaining`. */ + countDownload: () => void; + /** The ZIP route for this session. */ + archiveUrl: string; +} + +/** How long the selection may sit unsaved after the last heart. */ +const SAVE_DEBOUNCE_MS = 600; + +export interface ProofingContextType { favorites: Set; toggleFavorite: (token: string) => void; isFavorite: (token: string) => boolean; @@ -18,27 +60,68 @@ interface ProofingContextType { /** Selected asset tokens in album order — the payload for a ZIP download. */ getSelectedTokens: () => string[]; allowMailto: boolean; + /** + * `encodeEmail()` of the "email to photographer" recipient, when one is + * configured (#736). The modal decodes it in the browser; without it the + * button is hidden. + */ + encodedMailto?: string; /** The ZIP endpoint when the album offers downloads; undefined otherwise. */ downloadArchiveUrl?: string; + /** Present on a client proofing link only. */ + session?: ProofSessionState; } -const ProofingContext = createContext(null); - export function ProofingProvider({ children, albumTokens = [], albumName = 'Gallery', allowMailto = true, + encodedMailto, downloadArchiveUrl, + session: sessionInit, }: { children: React.ReactNode; albumTokens?: string[]; albumName?: string; allowMailto?: boolean; + encodedMailto?: string; downloadArchiveUrl?: string; + session?: ProofSessionInit; }) { const t = useDictionary(); - const [favorites, setFavorites] = useState>(new Set()); + // A session's selection comes from the server, so it can be rendered on the + // first pass; the anonymous mode hydrates from the browser in an effect below. + const [favorites, setFavorites] = useState>( + () => new Set(sessionInit?.selected ?? []), + ); + const [saveState, setSaveState] = useState('idle'); + const [submitted, setSubmitted] = useState(Boolean(sessionInit?.submitted)); + /** + * Edits made vs. edits the server has confirmed. A counter rather than a + * dirty flag, so a heart clicked while a save is in flight is not marked + * saved by that earlier request. + */ + const [edits, setEdits] = useState(0); + const editsRef = useRef(0); + const savedEdits = useRef(0); + /** `savedEdits` for rendering: what makes `unsaved` clear once a save lands. */ + const [savedEditsShown, setSavedEditsShown] = useState(0); + /** + * Starts from the page load and counts down with each download started here, + * so the dialog does not keep offering a download the limit has used up. + */ + const [downloadsRemaining, setDownloadsRemaining] = useState( + sessionInit?.downloadsRemaining ?? null, + ); + useEffect(() => { + editsRef.current = edits; + }, [edits]); + /** The selection as the save callbacks see it; kept current by the effect below. */ + const favoritesRef = useRef(favorites); + useEffect(() => { + favoritesRef.current = favorites; + }, [favorites]); const [isFilterActive, setIsFilterActive] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); @@ -48,6 +131,7 @@ export function ProofingProvider({ // an effect: both sources are browser-only, so reading them during render would // break SSR and produce a hydration mismatch. useEffect(() => { + if (sessionInit) return; try { const saved = localStorage.getItem(storageKey); const initialSet = new Set(); @@ -72,14 +156,103 @@ export function ProofingProvider({ // Mount-time hydration from an external store, not a render cascade: // the state lands in the same commit and never re-triggers this effect. - // eslint-disable-next-line react-hooks/set-state-in-effect setFavorites(initialSet); } catch (e) { console.error('Failed to load favorites', e); } - }, [storageKey, albumTokens]); + }, [storageKey, albumTokens, sessionInit]); + + const sessionToken = sessionInit?.token; + + /** Send the current selection, in album order. Resolves to success. */ + const saveNow = useCallback(async (): Promise => { + if (!sessionToken) return true; + const version = editsRef.current; + setSaveState('saving'); + const selected = albumTokens.filter((token) => favoritesRef.current.has(token)); + try { + const res = await fetch(`/api/proof/${encodeURIComponent(sessionToken)}/selection`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assets: selected }), + }); + if (res.status === 409) { + // Submitted from another tab or device: show the locked state here too. + setSubmitted(true); + setSaveState('saved'); + return false; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + savedEdits.current = Math.max(savedEdits.current, version); + setSavedEditsShown(savedEdits.current); + // A heart clicked while this request was in flight has its own save queued. + if (editsRef.current <= savedEdits.current) setSaveState('saved'); + return true; + } catch { + setSaveState('error'); + return false; + } + }, [sessionToken, albumTokens]); + + // Debounced autosave: a burst of hearts becomes one request. + useEffect(() => { + if (!sessionToken || edits <= savedEdits.current) return; + const timer = setTimeout(() => void saveNow(), SAVE_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [edits, sessionToken, saveNow]); + + // Leaving with an unsaved heart: send it anyway. keepalive lets the request + // outlive the page, which a normal fetch in `pagehide` would not. + useEffect(() => { + if (!sessionToken) return; + const flush = () => { + if (editsRef.current <= savedEdits.current) return; + const selected = albumTokens.filter((token) => favoritesRef.current.has(token)); + void fetch(`/api/proof/${encodeURIComponent(sessionToken)}/selection`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assets: selected }), + keepalive: true, + }).catch(() => {}); + }; + window.addEventListener('pagehide', flush); + return () => window.removeEventListener('pagehide', flush); + }, [sessionToken, albumTokens]); + + const submit = useCallback(async (): Promise => { + if (!sessionToken) return false; + if (editsRef.current > savedEdits.current && !(await saveNow())) return false; + try { + const res = await fetch(`/api/proof/${encodeURIComponent(sessionToken)}/submit`, { + method: 'POST', + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setSubmitted(true); + return true; + } catch { + setSaveState('error'); + return false; + } + }, [sessionToken, saveNow]); + + /** A session edit: counted for autosave, refused once submitted. */ + const editSelection = (update: (prev: Set) => Set) => { + // A submitted selection is the photographer's to reopen, not the client's. + if (submitted) return; + setEdits((n) => n + 1); + setFavorites(update); + }; const toggleFavorite = (token: string) => { + if (sessionInit) { + editSelection((prev) => { + const next = new Set(prev); + if (next.has(token)) next.delete(token); + else next.add(token); + return next; + }); + return; + } setFavorites((prev) => { const next = new Set(prev); if (next.has(token)) { @@ -99,6 +272,10 @@ export function ProofingProvider({ const isFavorite = (token: string) => favorites.has(token); const clearFavorites = () => { + if (sessionInit) { + editSelection(() => new Set()); + return; + } setFavorites(new Set()); try { localStorage.removeItem(storageKey); @@ -159,15 +336,24 @@ export function ProofingProvider({ getFormattedList, getSelectedTokens, allowMailto, + encodedMailto, downloadArchiveUrl, + session: sessionInit + ? { + saveState, + unsaved: edits > savedEditsShown, + submitted, + submit, + download: sessionInit.download, + downloadsRemaining, + countDownload: () => + setDownloadsRemaining((n) => (n === null ? null : Math.max(0, n - 1))), + archiveUrl: `/api/proof/${encodeURIComponent(sessionInit.token)}/archive`, + } + : undefined, }} > {children} ); } - -export function useProofing() { - const context = useContext(ProofingContext); - return context; -} diff --git a/components/ProofingLazy.tsx b/components/ProofingLazy.tsx new file mode 100644 index 0000000..b701080 --- /dev/null +++ b/components/ProofingLazy.tsx @@ -0,0 +1,29 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +/** + * The proofing provider, modal and session controls, loaded on demand. + * + * Proofing is off on most album pages, and these three are only rendered when + * it is on — yet imported statically they shipped in every album, essay and + * journal bundle. Through `next/dynamic` they become their own chunk: still + * rendered on the server (`ssr` stays on, so the hearts and the session bar + * are in the HTML as before) and preloaded by the page that renders them. + * + * All three come from `ProofingParts.ts`, so they arrive as one chunk. + * `useProofing()` itself lives in `useProofing.ts` so the grid and the + * lightbox can ask for it without pulling the provider back in. + */ + +export const ProofingProvider = dynamic(() => + import('./ProofingParts').then((m) => ({ default: m.ProofingProvider })), +); + +export const ProofingModal = dynamic(() => + import('./ProofingParts').then((m) => ({ default: m.ProofingModal })), +); + +export const ProofSessionControls = dynamic(() => + import('./ProofingParts').then((m) => ({ default: m.ProofSessionControls })), +); diff --git a/components/ProofingModal.tsx b/components/ProofingModal.tsx index f4105e4..5f350bf 100644 --- a/components/ProofingModal.tsx +++ b/components/ProofingModal.tsx @@ -5,6 +5,7 @@ import { useProofing } from './ProofingContext'; import { IconCheck, IconCopy, IconLink } from './Icons'; import { useDictionary } from './I18nProvider'; import { useModalDialog } from '@/hooks/useModalDialog'; +import { decodeEmail } from '@/lib/emailObfuscation'; export function ProofingModal() { const t = useDictionary(); @@ -27,22 +28,28 @@ export function ProofingModal() { if (!proofing || !proofing.isModalOpen) return null; const { - favorites, setIsModalOpen, getProofingUrl, getFormattedList, getSelectedTokens, clearFavorites, allowMailto, + encodedMailto, downloadArchiveUrl, } = proofing; - // What the archive would actually receive. Gating on this rather than on - // `favorites.size` means a favourite left over from another album (they share - // the provider's storage key when no `albumName` is passed) can never light up - // a button that would post an empty selection and 404. + // What the archive would actually receive, and the only count shown here. + // `favorites.size` also counts a favourite left over from another album (they + // share a storage key when their names slug alike) or a photo since removed + // from this one, so it would light up a button that posts an empty selection + // and 404s, and put the wrong number in the title and the email subject. const selectedCount = getSelectedTokens().length; + // Decoded in the browser only: the address never travels in the clear. The + // email button is hidden without a recipient, so a mailto is never built with + // an empty To: — which opened a compose that went nowhere (#736). + const mailtoRecipient = encodedMailto ? decodeEmail(encodedMailto) : ''; + /** Copy to the clipboard, or show the text to copy by hand where it is unavailable. */ const copy = (kind: 'link' | 'list', text: string) => { if (!navigator.clipboard) { @@ -64,9 +71,12 @@ export function ProofingModal() { const handleCopyList = () => copy('list', getFormattedList()); const handleMailto = () => { - const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size)); + const subject = encodeURIComponent(t.proofing.mailSubject(getSelectedTokens().length)); const body = encodeURIComponent(t.proofing.mailBody(getFormattedList(), getProofingUrl())); - window.location.href = `mailto:?subject=${subject}&body=${body}`; + // Percent-encode the address, keeping '@' readable, so a value from the + // settings cannot smuggle its own `?bcc=`/`&…` header fields into the draft. + const to = encodeURIComponent(mailtoRecipient).replace(/%40/g, '@'); + window.location.href = `mailto:${to}?subject=${subject}&body=${body}`; }; const handleDownloadSelection = () => { @@ -143,7 +153,7 @@ export function ProofingModal() { }} >

- {t.proofing.modalTitle(favorites.size)} + {t.proofing.modalTitle(selectedCount)}

; +} + +function renderControls(overrides: Partial = {}) { + return render( + + + + , + ); +} + +const selectionLink = () => screen.queryByText('Download selection (.zip)'); + +let fetchMock: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('ProofSessionControls', () => { + it('offers the selection ZIP when nothing is waiting to be saved', () => { + renderControls(); + fireEvent.click(screen.getByText('Review & submit')); + expect(selectionLink()).not.toBeNull(); + }); + + it('withholds the selection ZIP while a heart waits out the autosave debounce', async () => { + renderControls(); + // A first save, so the state reads 'saved' rather than 'idle'. + fireEvent.click(screen.getByText('heart t1')); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Another heart, and the dialog opened before its save is sent. + fireEvent.click(screen.getByText('heart t1')); + fireEvent.click(screen.getByText('Review & submit')); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(selectionLink()).toBeNull(); + + // Once the save lands the link comes back, now for the saved selection. + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(selectionLink()).not.toBeNull(); + }); + + it('counts a started download against the limit shown', () => { + renderControls({ downloadsRemaining: 2 }); + fireEvent.click(screen.getByText('Review & submit')); + expect(screen.getByText('2 downloads left')).toBeTruthy(); + + const link = screen.getByText('Download all photos (.zip)'); + // jsdom does not navigate; keep it from trying. + link.addEventListener('click', (e) => e.preventDefault()); + fireEvent.click(link); + expect(screen.getByText('1 download left')).toBeTruthy(); + + fireEvent.click(screen.getByText('Download all photos (.zip)')); + expect(screen.queryByText('Download all photos (.zip)')).toBeNull(); + expect(selectionLink()).toBeNull(); + }); +}); diff --git a/components/__tests__/ProofingModal.test.tsx b/components/__tests__/ProofingModal.test.tsx index a46ae27..14e3985 100644 --- a/components/__tests__/ProofingModal.test.tsx +++ b/components/__tests__/ProofingModal.test.tsx @@ -1,19 +1,30 @@ // @vitest-environment jsdom -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; import { ProofingModal } from '../ProofingModal'; +import { encodeEmail } from '@/lib/emailObfuscation'; /** * `navigator.clipboard` only exists in a secure context. A portfolio reached * over plain http on a LAN is not one, and both copy buttons used to throw on * the missing API and appear dead. They now show the text to copy by hand. + * + * The context is mutable so a test can turn the email action on and off: the + * recipient is `proofing.email`, else the footer contact email (#736), encoded + * until the browser decodes it. */ const URL_ = 'http://192.168.1.20:7211/deutschland/kloster-chorin?fav=abc'; const LIST = '#1, #3'; +const RECIPIENT = 'hello@example.com'; -vi.mock('../ProofingContext', () => ({ - useProofing: () => ({ +const { proofing } = vi.hoisted(() => ({ proofing: { current: {} as Record } })); + +vi.mock('../ProofingContext', () => ({ useProofing: () => proofing.current })); + +/** The context the modal reads; reset per test so flags cannot leak. */ +function setContext(overrides: Record = {}) { + proofing.current = { favorites: new Set(['a', 'b']), isModalOpen: true, setIsModalOpen: vi.fn(), @@ -22,16 +33,21 @@ vi.mock('../ProofingContext', () => ({ getSelectedTokens: () => ['a', 'b'], clearFavorites: vi.fn(), allowMailto: false, + encodedMailto: undefined, downloadArchiveUrl: undefined, - }), -})); + ...overrides, + }; +} const setClipboard = (value: unknown) => Object.defineProperty(navigator, 'clipboard', { value, configurable: true }); +beforeEach(() => setContext()); + afterEach(() => { cleanup(); setClipboard(undefined); + vi.unstubAllGlobals(); }); describe('ProofingModal copy buttons', () => { @@ -66,3 +82,42 @@ describe('ProofingModal copy buttons', () => { expect(screen.queryByLabelText('Copy this link')).toBeNull(); }); }); + +describe('ProofingModal email to photographer (#736)', () => { + it('hides the email action when no recipient is configured', () => { + setContext({ allowMailto: true, encodedMailto: undefined }); + render(); + expect(screen.queryByText(/Send Email/)).toBeNull(); + }); + + it('hides the email action when the site turned it off', () => { + setContext({ allowMailto: false, encodedMailto: encodeEmail(RECIPIENT) }); + render(); + expect(screen.queryByText(/Send Email/)).toBeNull(); + }); + + it('addresses the compose to the configured recipient', () => { + setContext({ allowMailto: true, encodedMailto: encodeEmail(RECIPIENT) }); + const location = { href: '' }; + vi.stubGlobal('location', location); + render(); + fireEvent.click(screen.getByText(/Send Email/)); + expect(location.href.startsWith(`mailto:${RECIPIENT}?subject=`)).toBe(true); + }); + + it('keeps a crafted address from adding its own header fields', () => { + setContext({ allowMailto: true, encodedMailto: encodeEmail('a@b.c?bcc=evil@x.y') }); + const location = { href: '' }; + vi.stubGlobal('location', location); + render(); + fireEvent.click(screen.getByText(/Send Email/)); + // The `?` and `=` are escaped, so the draft gains no bcc field. + expect(location.href.startsWith('mailto:a@b.c%3Fbcc%3Devil@x.y?subject=')).toBe(true); + }); + + it('keeps the plain address out of the rendered markup', () => { + setContext({ allowMailto: true, encodedMailto: encodeEmail(RECIPIENT) }); + const { container } = render(); + expect(container.innerHTML).not.toContain(RECIPIENT); + }); +}); diff --git a/components/__tests__/ProofingSession.test.tsx b/components/__tests__/ProofingSession.test.tsx new file mode 100644 index 0000000..6abd455 --- /dev/null +++ b/components/__tests__/ProofingSession.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, act } from '@testing-library/react'; +import { ProofingProvider, useProofing, type ProofSessionInit } from '../ProofingContext'; + +/** + * Session mode of the proofing context: the selection starts from the server, + * a burst of hearts becomes one save, and a submitted selection stays put. + */ + +function Probe() { + const p = useProofing()!; + return ( +
+ {p.favorites.size} + {p.session?.saveState} + {String(p.session?.submitted)} + + + +
+ ); +} + +const TOKEN = 'k'.repeat(32); + +function renderSession(overrides: Partial = {}) { + return render( + + + , + ); +} + +let fetchMock: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + // Node 26 defines its own global localStorage (undefined without + // --localstorage-file), which shadows jsdom's; give the test a working one. + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('ProofingProvider session mode', () => { + it('starts from the server selection, not localStorage', () => { + localStorage.setItem('folio_fav_wedding', JSON.stringify(['t1', 't2'])); + renderSession(); + expect(screen.getByTestId('count').textContent).toBe('1'); + }); + + it('debounces hearts into one save, in album order', async () => { + renderSession(); + fireEvent.click(screen.getByText('t2')); + fireEvent.click(screen.getByText('t1')); + expect(fetchMock).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`/api/proof/${TOKEN}/selection`); + expect(init.method).toBe('PUT'); + expect(JSON.parse(init.body as string)).toEqual({ assets: ['t1', 't2', 't3'] }); + expect(screen.getByTestId('state').textContent).toBe('saved'); + }); + + it('reports a failed save', async () => { + fetchMock.mockResolvedValue(new Response('{}', { status: 500 })); + renderSession(); + fireEvent.click(screen.getByText('t1')); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.getByTestId('state').textContent).toBe('error'); + }); + + it('saves pending hearts before submitting, then locks', async () => { + renderSession(); + fireEvent.click(screen.getByText('t1')); + await act(async () => { + fireEvent.click(screen.getByText('submit')); + await vi.advanceTimersByTimeAsync(0); + }); + + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toEqual([`/api/proof/${TOKEN}/selection`, `/api/proof/${TOKEN}/submit`]); + expect(screen.getByTestId('submitted').textContent).toBe('true'); + + fireEvent.click(screen.getByText('t2')); + expect(screen.getByTestId('count').textContent).toBe('2'); + }); + + it('ignores hearts on a submitted selection', () => { + renderSession({ submitted: true }); + fireEvent.click(screen.getByText('t1')); + expect(screen.getByTestId('count').textContent).toBe('1'); + }); +}); diff --git a/components/__tests__/colorTokens.test.ts b/components/__tests__/colorTokens.test.ts new file mode 100644 index 0000000..c4bf56d --- /dev/null +++ b/components/__tests__/colorTokens.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { THEME_PRESETS } from '@/lib/config/theme'; + +/** + * Colours that used to be hard-coded and failed in one mode or with one + * accent (QA T-1, T-2, T-5, T-6, T-8). The values live in app/tokens.css; + * these checks read them from there, so a palette change that breaks one + * fails here. + */ +const read = (...p: string[]) => fs.readFileSync(path.join(process.cwd(), ...p), 'utf8'); +const tokens = read('app', 'tokens.css'); + +type RGBA = { r: number; g: number; b: number; a: number }; + +function parse(value: string): RGBA { + const v = value.trim(); + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v)?.[1]; + if (hex) { + const h = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex; + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + a: 1, + }; + } + const rgba = /^rgba?\(([^)]+)\)$/.exec(v)?.[1]; + if (!rgba) throw new Error(`unparsed colour ${value}`); + const [r, g, b, a = 1] = rgba.split(',').map(Number); + return { r, g, b, a }; +} + +function over(fg: RGBA, bg: RGBA): RGBA { + const m = (x: number, y: number) => x * fg.a + y * (1 - fg.a); + return { r: m(fg.r, bg.r), g: m(fg.g, bg.g), b: m(fg.b, bg.b), a: 1 }; +} + +function ratio(a: RGBA, b: RGBA): number { + const lum = (c: RGBA) => + [c.r, c.g, c.b] + .map((v) => v / 255) + .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) + .reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); + const [x, y] = [lum(a), lum(b)]; + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} + +function block(selector: string): string | undefined { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`).exec(tokens)?.[1]; +} + +function token(body: string | undefined, name: string): string | undefined { + return body ? new RegExp(`--${name}:\\s*([^;]+);`).exec(body)?.[1] : undefined; +} + +const ROOT = block(':root'); +const LIGHT = block("[data-theme='light']"); +const SURFACES = ['bg-primary', 'bg-secondary', 'bg-card', 'bg-card-hover']; + +/** Every opaque surface token a preset resolves to in one mode. */ +function surfaces(preset: string, mode: 'dark' | 'light'): string[] { + const own = block( + mode === 'dark' ? `[data-preset='${preset}']` : `[data-preset='${preset}'][data-theme='light']`, + ); + const base = mode === 'dark' ? ROOT : LIGHT; + return SURFACES.map((s) => token(own, s) ?? token(base, s)!).filter((v) => v.startsWith('#')); +} + +describe('--error (T-8)', () => { + it('is defined for both modes', () => { + expect(token(ROOT, 'error')).toBeDefined(); + expect(token(LIGHT, 'error')).toBeDefined(); + }); + + for (const preset of Object.keys(THEME_PRESETS)) { + for (const mode of ['dark', 'light'] as const) { + it(`${preset} (${mode}): clears 4.5:1 on every surface`, () => { + const error = parse(token(mode === 'dark' ? ROOT : LIGHT, 'error')!); + for (const surface of surfaces(preset, mode)) { + expect(ratio(error, parse(surface)), surface).toBeGreaterThanOrEqual(4.5); + } + }); + } + } + + it('replaces the fixed reds in the error texts', () => { + for (const file of [ + ['app', 'contact', 'contact.css'], + ['components', 'PasswordGate.module.css'], + ['components', 'ProofingModal.tsx'], + ]) { + expect(read(...file), file.join('/')).not.toMatch(/#e74c3c|#ff4d4f/i); + } + }); +}); + +describe('lightbox tokens (T-1, T-2)', () => { + const lb = (name: string) => parse(token(ROOT, `lightbox-${name}`)!); + // The two overlays the lightbox paints (the default, and studio-modern's), + // over the lightest page a light preset has — the worst case for white text. + const lightest = parse('#ffffff'); + const overlays = [parse('rgba(0, 0, 0, 0.85)'), parse('rgba(10, 10, 10, 0.92)')].map((o) => + over(o, lightest), + ); + + it('keeps text and the saved-favourite colour at 4.5:1 on the overlay', () => { + for (const bg of overlays) { + for (const name of ['text', 'text-secondary', 'text-muted', 'fav']) { + expect(ratio(over(lb(name), bg), bg), name).toBeGreaterThanOrEqual(4.5); + } + } + }); + + it('points the text tokens at them inside the overlay in light mode', () => { + const css = read('components', 'Lightbox.module.css'); + const rule = /:global\(\[data-theme='light'\]\) \.overlay \{([^}]*)\}/.exec(css)?.[1]; + expect(rule).toBeDefined(); + expect(rule).toMatch(/--text-primary:\s*var\(--lightbox-text\)/); + expect(rule).toMatch(/--text-secondary:\s*var\(--lightbox-text-secondary\)/); + expect(rule).toMatch(/--text-muted:\s*var\(--lightbox-text-muted\)/); + }); + + it('drops the inline favourite colour that overrode the preset', () => { + const tsx = read('components', 'Lightbox.tsx'); + expect(tsx).not.toMatch(/#ff4d4f/i); + expect(tsx).not.toMatch(/color: isFav/); + }); +}); + +describe('text on the accent (T-5, T-6)', () => { + it('uses --on-accent instead of a fixed white', () => { + for (const file of [ + ['components', 'ProofingModal.tsx'], + ['components', 'ProofSessionControls.tsx'], + ]) { + expect(read(...file), file.join('/')).not.toMatch(/color: '#fff'/); + } + const studioModern = read('app', 'themes', 'studio-modern.css'); + const button = /\[data-preset='studio-modern'\] \.empty-state__button \{([^}]*)\}/.exec( + studioModern, + )?.[1]; + expect(button).toBeDefined(); + expect(button).not.toMatch(/color:\s*#fff/i); + }); +}); diff --git a/components/__tests__/focusManagement.test.tsx b/components/__tests__/focusManagement.test.tsx new file mode 100644 index 0000000..4fba191 --- /dev/null +++ b/components/__tests__/focusManagement.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { trapTabKey } from '@/lib/focusTrap'; +import { MobileNav } from '../MobileNav'; + +vi.mock('next/navigation', () => ({ usePathname: () => '/' })); + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; +}); + +function tab(container: HTMLElement, shiftKey = false) { + const e = new KeyboardEvent('keydown', { key: 'Tab', shiftKey, cancelable: true }); + const moved = trapTabKey(container, e); + return { moved, prevented: e.defaultPrevented }; +} + +describe('trapTabKey (#696)', () => { + function dialog() { + document.body.innerHTML = ` + +
+ + + b +
`; + return document.getElementById('dlg')!; + } + + it('wraps Tab from the last element to the first', () => { + const dlg = dialog(); + document.getElementById('b')!.focus(); + expect(tab(dlg)).toEqual({ moved: true, prevented: true }); + expect(document.activeElement?.id).toBe('a'); + }); + + it('wraps Shift+Tab from the first element to the last', () => { + const dlg = dialog(); + document.getElementById('a')!.focus(); + tab(dlg, true); + expect(document.activeElement?.id).toBe('b'); + }); + + it('leaves Tab between inner elements to the browser', () => { + const dlg = dialog(); + document.getElementById('a')!.focus(); + expect(tab(dlg)).toEqual({ moved: false, prevented: false }); + }); + + it('pulls escaped focus back in', () => { + const dlg = dialog(); + document.getElementById('outside')!.focus(); + tab(dlg); + expect(document.activeElement?.id).toBe('a'); + }); +}); + +describe('MobileNav focus (#696)', () => { + it('moves focus into the panel and back to the button on Esc', () => { + render( + + Home + About + , + ); + const button = screen.getByRole('button', { name: 'Open menu' }); + fireEvent.click(button); + expect(document.activeElement?.textContent).toBe('Home'); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(document.activeElement).toBe(button); + expect(button.getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/components/useProofing.ts b/components/useProofing.ts new file mode 100644 index 0000000..89742a0 --- /dev/null +++ b/components/useProofing.ts @@ -0,0 +1,18 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { ProofingContextType } from './ProofingContext'; + +/** + * The proofing context on its own, apart from the provider in + * `ProofingContext.tsx`. The grid, the essay and the lightbox read it on every + * album page, proofing or not; the provider, the modal and the session + * controls are only loaded where proofing is on (`ProofingLazy.tsx`). Keeping + * the context here is what lets them stay out of the page's first bundle. + */ +export const ProofingContext = createContext(null); + +/** The proofing state, or null outside a provider — proofing is off. */ +export function useProofing() { + return useContext(ProofingContext); +} diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example index bcfa766..70ffb9f 100644 --- a/content/gallery.yaml.example +++ b/content/gallery.yaml.example @@ -146,3 +146,11 @@ subpages: layout: essay albums: - "album-uuid-iceland-1" + + # ── Content Page in the Menu ───────────────────────────────────── + # A timeless text page (pricing, workshops, FAQ) lives in + # content/pages/.md and is served at /. Listing it here puts it + # in the header menu at this position; leave it out and the page is still + # reachable at its URL, just not in the menu. Create and edit pages under + # Admin → Pages. See docs/gallery-config.md#content-pages. + - page: pricing diff --git a/content/settings.yaml.example b/content/settings.yaml.example index ba38481..cc04e98 100644 --- a/content/settings.yaml.example +++ b/content/settings.yaml.example @@ -111,6 +111,16 @@ footer: email: "hello@example.com" website: "https://example.com" +# ── Client proofing ────────────────────────────────────────────── +# Hearts on every photo, a selection bar and an export dialog. A subpage's own +# `proofing:` in gallery.yaml overrides `enabled` in either direction. +# "Send by email" addresses `email`, else footer.email, and is hidden when +# neither is set. +# proofing: +# enabled: true +# allowMailto: true +# email: "proofs@example.com" + # ── Privacy policy ─────────────────────────────────────────────── # The text lives in content/privacy.md, written in the admin panel under # Settings → Legal. /privacy is shown, and linked in the footer, while this is diff --git a/docs/admin-panel.md b/docs/admin-panel.md index cda424a..b376303 100644 --- a/docs/admin-panel.md +++ b/docs/admin-panel.md @@ -39,6 +39,7 @@ Each area has its own URL, so a section can be bookmarked and the browser's back | **Pages** | `/admin/pages` | `content/gallery.yaml` | | **Journal** | `/admin/journal` | `content/journal/.md` | | **Settings** | `/admin/settings` | `content/settings.yaml`, `content/about.md` | +| **Proofing** | `/admin/proofing` | `content/proofing.json` | | **Analytics** | `/admin/analytics` | nothing — read-only | | **Diagnostics** | `/admin/diagnostics` | nothing — read-only | | **Help** | `/admin/help` | nothing — reference only | @@ -127,6 +128,14 @@ Theme presets, grid layouts, photo frames, hero styles and the Google search sni **General → Favicon** accepts an SVG, PNG, ICO or JPEG. It is stored in the writable `content/` volume and served through `/api/favicon` under a policy that stops an uploaded SVG from executing script. **Reset** restores the bundled default. +## Proofing + +Private client links: pick a client name and any Immich album — published or +not — and optionally an expiry date and download rights. Each card shows the +link's state (open, submitted, expired), what the client picked, and the export +buttons. See [Client Proofing Links](gallery-config.md#client-proofing-links) +for how the client side works. + ## Analytics View counts per page and album, read from `content/analytics.json`. No cookies, no third party, nothing leaving your server. Switch the collection off entirely in **Settings → General**; the tracking endpoint then refuses to record. diff --git a/docs/deployment.md b/docs/deployment.md index a543c46..6a63975 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -215,7 +215,7 @@ CDN_URL=https://cdn.example.com ``` ``` -https://cdn.example.com/api/image/v2:…?size=preview&w=1080&q=75 +https://cdn.example.com/api/image/v2:…?size=preview&w=1440 ``` The CDN uses Immich Folio as its origin. The first request for a photo is a @@ -230,7 +230,7 @@ Folio URL, then: - **Cache `/api/image/*` and `/api/video/*`**, and nothing else. Pages are rendered per request, and other routes check cookies. -- **Include the full query string in the cache key.** `size`, `w`, `q` and the +- **Include the full query string in the cache key.** `size`, `w` and the `IMAGE_CACHE_VERSION` buster `v` all select a different file. - **Respect the origin's `Cache-Control`.** Photos come back `public, max-age=31536000, immutable`; errors (429, 503, 404) come back diff --git a/docs/gallery-config.md b/docs/gallery-config.md index 82862b5..f9f5dda 100644 --- a/docs/gallery-config.md +++ b/docs/gallery-config.md @@ -35,6 +35,7 @@ cp content/settings.yaml.example content/settings.yaml - [Site Behaviour](#site-behaviour) - [Navigation Links](#navigation-links) - [Client Proofing](#client-proofing) +- [Client Proofing Links](#client-proofing-links) - [Image Protection & Watermark](#image-protection--watermark) - [SEO](#seo) - [Footer](#footer) @@ -269,6 +270,12 @@ so an edited URL cannot reach anything else. The ZIP keeps the original filenames (deduplicated on collision) and is streamed, so album size does not drive memory use. +One visitor (by client IP, see `TRUSTED_PROXY_HOPS`) can have at most two ZIPs +downloading at once, album and proofing downloads counted together; a third +gets a "too many requests" page until one finishes. A ZIP whose download stops +reading for 60 seconds is closed, so an abandoned or stalled download does not +hold its connection to Immich open. + ## Password Protection A password can be set on the whole site, on a subpage, on a single album, and on @@ -617,8 +624,16 @@ Configured in `settings.yaml`: proofing: enabled: true # hearts, selection bar and export modal (default: true) allowMailto: true # offer "send by email" alongside the copyable list (default: true) + email: proofs@example.com # recipient of that email (default: footer.email) ``` +The email draft goes to `proofing.email`, or to the footer contact email +(`footer.email`) when that is unset. With neither configured the dialog hides +the email button rather than opening a draft with an empty **To:**. The address +is set in the admin panel under **Settings → General → Proofing email**, and it +reaches the page encoded like the footer address, so it does not appear in the +HTML in plain text. + A subpage overrides `enabled` in either direction — useful to keep proofing off across a public portfolio and switch it on for a single client handover: @@ -641,6 +656,59 @@ album reached without a subpage follows `proofing.enabled`. > controls only when its subpage sets `proofing: true` **explicitly** — the > global default does not reach into essays. Journal entries never show them. +## Client Proofing Links + +The proofing above is anonymous: picks live in the visitor's browser and come +back to you as a link or an email, if they come back at all. A **client +proofing link** is the handover version. Create one per client in +**Admin → Proofing**: + +- **Client** — a name for your overview and the greeting on the page. +- **Album** — any Immich album. It does not have to be in `gallery.yaml`; the + link itself is what grants access to it. +- **Valid until** — optional. From the day after, the link shows "This link has + expired" and every action on it is refused. +- **Downloads** — none, the client's selection as a ZIP, or the whole album as + a ZIP, with an optional limit on how many ZIPs the link may start. + +The client opens `https://your-site/proof/` and hearts photos as usual. +Every change is **saved on the server** within a second, so they can stop and +come back later, from another device too. **Submit selection** locks it and +notifies you. Until then, you can watch the picks come in under +**Admin → Proofing**. **Reopen for changes** unlocks a submitted selection. + +**Export.** Each link offers: + +- **Copy file names (Lightroom)** — the selected file names without extension, + comma-separated. Pasted into Lightroom's or Capture One's filename filter, + `IMG_0412` finds the RAW as well as the JPEG the client saw. +- **CSV** — position in the album, file name and capture time. +- **TXT** — full file names, one per line. + +**Notification.** Set `PROOFING_WEBHOOK_URL` and every first submit POSTs JSON +to it. The body carries `content` (Discord), `text` (Slack, Mattermost), +`title` and `message` (Gotify) and a structured `proofing` object (n8n, Home +Assistant, your own script), so most receivers work without an adapter. With +`SITE_URL` set, the message links straight to the selection in the admin panel. +A failing webhook never fails the client's submit. + +```env +PROOFING_WEBHOOK_URL=https://discord.com/api/webhooks/… +``` + +**Security notes.** + +- The token in the link is the only credential: 192 random bits, never shown + in the admin panel's own URLs. Treat the link like a password; delete it in + the admin panel to revoke it at once. +- Links are stored in `content/proofing.json` (mode `0600`), next to + `analytics.json`, and are not part of the YAML backups. +- A site-wide password still applies: on a locked site, the client needs it + too. +- Proofing pages are `noindex`, left out of the sitemap, and send no + `Referer`. +- A subpage slugged `proof` would be hidden behind this route. + ## Image Protection & Watermark ```yaml @@ -744,6 +812,79 @@ The admin panel's **Settings → About** section edits the same file — portrai name, location, gear list and bio — so `about.md` does not have to be written by hand. +## Content Pages + +Pages beyond the gallery — pricing and packages, workshops, press, a booking +FAQ — are content pages. Each one is a file, `content/pages/.md`, served +at `/`. + +```markdown +--- +title: 'Pricing' +description: 'Packages and rates for portrait and wedding sessions' +draft: true +--- + +## Portrait sessions + +One hour on location, 25 edited photos. + +![0a1b2c3d-…:wide](A session in the Highlands) +``` + +The frontmatter keys are `title`, `description` (used for search engines and +link previews), `password` and `draft`. The body uses the journal's block +syntax ([Journal & Photo Essays](journal.md)): headings, text and photo blocks +(`:wide` or `:fullbleed`). A page has no date, no reading time and no index, +and it has no map block. + +### In the menu + +A page appears in the header menu through a reference among the subpages in +`gallery.yaml`, at the position it should take: + +```yaml +subpages: + - name: Landscapes + albums: + - 'album-uuid-1' + - page: pricing +``` + +A page without a reference is still reachable at `/`, but it is not in +the menu, which suits a page that is only shared by link. The menu stays flat: +a page cannot sit inside a subpage's sections. A reference to a page whose file +does not exist is skipped, and the config doctor reports it. + +### Slugs + +A page's slug must not be taken by a subpage, a standalone album, a journal +entry or one of the built-in routes (`about`, `map`, `journal`, `impressum`, +`privacy`, `install`, `admin`, `gate`, `api`, `proof`, `contact`). The admin +panel refuses to save a page on such a slug, and the doctor reports a collision +in a hand-edited file. Renaming a page's slug breaks old links: there are no +automatic redirects. + +### Draft and password + +A draft is visible only to a signed-in admin, and it is left out of the menu and +the sitemap. A `password` locks the page like a journal entry, with its own +cookie (`lb_auth_page_`). The site password, when one is set, applies +before either. Password-protected pages are not listed in the sitemap. + +### In the admin panel + +Admin → Pages lists pages next to the subpages, with a document icon and a +PAGE tag, and a separate "Not in menu" group for pages without a reference. +**+ New page** creates one (it starts blank and as a draft). Select a page to +edit its title, URL, draft flag, password and description, and to switch +"Show in menu"; dragging a page into or out of "Not in menu" does the same. +The menu change is saved with **Save Changes**, like any other change to the +structure. **Edit content →** opens the journal's block editor with a live +preview. Every save keeps a backup in `content/pages/.backups/`, and deleting a +page that is in the menu also removes its `gallery.yaml` reference; both files +are backed up first. + ## Journal & Photo Essays Long-form storytelling with fullbleed photos, side-by-side pairs, quotes and diff --git a/docs/screenshots/admin-journal-list.png b/docs/screenshots/admin-journal-list.png index e2c8b53..bf8d5b1 100644 Binary files a/docs/screenshots/admin-journal-list.png and b/docs/screenshots/admin-journal-list.png differ diff --git a/docs/screenshots/admin-journal-settings.png b/docs/screenshots/admin-journal-settings.png index 66cafdf..cf72987 100644 Binary files a/docs/screenshots/admin-journal-settings.png and b/docs/screenshots/admin-journal-settings.png differ diff --git a/docs/screenshots/admin-journal-studio.png b/docs/screenshots/admin-journal-studio.png index 06009f9..d2b5ad3 100644 Binary files a/docs/screenshots/admin-journal-studio.png and b/docs/screenshots/admin-journal-studio.png differ diff --git a/docs/screenshots/admin-login.png b/docs/screenshots/admin-login.png index b36cf13..a0bd65a 100644 Binary files a/docs/screenshots/admin-login.png and b/docs/screenshots/admin-login.png differ diff --git a/docs/screenshots/admin-page-builder.png b/docs/screenshots/admin-page-builder.png index 77ad431..551b99f 100644 Binary files a/docs/screenshots/admin-page-builder.png and b/docs/screenshots/admin-page-builder.png differ diff --git a/docs/screenshots/admin-settings.png b/docs/screenshots/admin-settings.png index 28e315d..3729029 100644 Binary files a/docs/screenshots/admin-settings.png and b/docs/screenshots/admin-settings.png differ diff --git a/docs/screenshots/header.png b/docs/screenshots/header.png index 06b6e14..3710344 100644 Binary files a/docs/screenshots/header.png and b/docs/screenshots/header.png differ diff --git a/docs/screenshots/journal-entry.png b/docs/screenshots/journal-entry.png index d1ca41f..04901f3 100644 Binary files a/docs/screenshots/journal-entry.png and b/docs/screenshots/journal-entry.png differ diff --git a/docs/screenshots/journal-index.png b/docs/screenshots/journal-index.png index 0c912e1..8397861 100644 Binary files a/docs/screenshots/journal-index.png and b/docs/screenshots/journal-index.png differ diff --git a/hooks/useModalDialog.ts b/hooks/useModalDialog.ts index 26e9672..93936e0 100644 --- a/hooks/useModalDialog.ts +++ b/hooks/useModalDialog.ts @@ -27,8 +27,22 @@ import { useEffect, useRef } from 'react'; * have to call the hook — hooks may not run conditionally. Without it the * effect would run once against an unmounted card, bail out, and never run * again, because `onClose` alone does not change when the modal opens. + * + * Dialogs nest — the album drawer opens the asset picker, the backup manager + * a confirmation. Every open dialog listens on `document`, so only the one + * opened last handles a key; otherwise one Escape closed the whole stack. + * + * Escape is also left alone while the focused element owns it: an expanded + * combobox (the admin Listbox closes its popup) or a dnd-kit item picked up + * with the keyboard (Escape cancels the move). The listener runs in the + * capture phase and stops propagation, so neither would see the key. */ +/** Open dialogs, innermost last. */ +const openDialogs: object[] = []; + +const ESCAPE_OWNERS = '[aria-expanded="true"], [aria-roledescription][aria-pressed="true"]'; + const FOCUSABLE = [ 'a[href]', 'button:not([disabled])', @@ -67,6 +81,8 @@ export function useModalDialog(onClose: () => void, active = true) { if (!active || !card) return; const previous = document.activeElement as HTMLElement | null; + const token = {}; + openDialogs.push(token); // React has already applied `autoFocus` by the time the effect runs, so // an existing focus inside the card is respected rather than overridden. @@ -75,7 +91,10 @@ export function useModalDialog(onClose: () => void, active = true) { } function onKeyDown(e: KeyboardEvent) { + if (openDialogs[openDialogs.length - 1] !== token) return; if (e.key === 'Escape') { + const active = document.activeElement; + if (active && card?.contains(active) && active.matches(ESCAPE_OWNERS)) return; e.stopPropagation(); onCloseRef.current(); return; @@ -105,6 +124,7 @@ export function useModalDialog(onClose: () => void, active = true) { document.addEventListener('keydown', onKeyDown, true); return () => { document.removeEventListener('keydown', onKeyDown, true); + openDialogs.splice(openDialogs.indexOf(token), 1); // Only take the focus back if it is still inside the closing dialog. // Something else may legitimately have claimed it in the meantime. if (previous && document.contains(previous)) previous.focus(); diff --git a/lib/__tests__/asset-selection.test.ts b/lib/__tests__/asset-selection.test.ts new file mode 100644 index 0000000..efa1d47 --- /dev/null +++ b/lib/__tests__/asset-selection.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { canSelectMore, matchesQuery, toggleSelection } from '../admin/assetSelection'; + +describe('toggleSelection (#602)', () => { + it('appends in click order and removes on a second toggle', () => { + let s = toggleSelection([], 'a'); + s = toggleSelection(s, 'b'); + s = toggleSelection(s, 'c'); + expect(s).toEqual(['a', 'b', 'c']); + expect(toggleSelection(s, 'b')).toEqual(['a', 'c']); + }); + + it('refuses assets the caller already uses', () => { + expect(toggleSelection(['a'], 'x', { disabled: new Set(['x']) })).toEqual(['a']); + }); + + it('stops at max but still lets you deselect', () => { + const full = ['a', 'b']; + expect(toggleSelection(full, 'c', { max: 2 })).toEqual(['a', 'b']); + expect(toggleSelection(full, 'a', { max: 2 })).toEqual(['b']); + }); + + it('replaces the pick when max is 1', () => { + expect(toggleSelection(['a'], 'b', { max: 1 })).toEqual(['b']); + }); + + it('does not mutate its input', () => { + const s = ['a']; + toggleSelection(s, 'b'); + expect(s).toEqual(['a']); + }); +}); + +describe('canSelectMore', () => { + it('is unlimited without max, bounded with it, and always open for a single slot', () => { + expect(canSelectMore(['a', 'b', 'c'])).toBe(true); + expect(canSelectMore(['a'], 2)).toBe(true); + expect(canSelectMore(['a', 'b'], 2)).toBe(false); + expect(canSelectMore(['a'], 1)).toBe(true); + }); +}); + +describe('matchesQuery', () => { + const asset = { originalFileName: 'IMG_4021.HEIC' }; + it('matches a case-insensitive filename fragment', () => { + expect(matchesQuery(asset, 'img_40')).toBe(true); + expect(matchesQuery(asset, ' 4021 ')).toBe(true); + expect(matchesQuery(asset, 'DSC')).toBe(false); + }); + it('matches everything for an empty query', () => { + expect(matchesQuery(asset, ' ')).toBe(true); + }); +}); diff --git a/lib/__tests__/auth-page.test.ts b/lib/__tests__/auth-page.test.ts new file mode 100644 index 0000000..5011c2e --- /dev/null +++ b/lib/__tests__/auth-page.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest'; + +/** + * Content page passwords (#722): their own cookie, `lb_auth_page_`, + * through the same authenticate()/isAuthenticated() path as every other gate, + * and a token that no other gate of the same slug and password accepts. + */ +vi.mock('@/lib/config', () => ({ + getConfig: () => ({ + authSecret: 'test-auth-secret-32-chars-long-min', + standaloneAlbums: [], + albumPasswords: {}, + subpages: [{ name: 'Pricing', slug: 'pricing', albumIds: [], password: 'shared' }], + }), +})); + +vi.mock('@/lib/admin/pages-service', () => ({ + readPageSync: (slug: string) => + slug === 'pricing' || slug === 'open' + ? { + slug, + rawMarkdown: '', + parsed: { + frontmatter: slug === 'pricing' ? { password: 'shared' } : {}, + blocks: [], + referencedAssetIds: [], + }, + } + : null, +})); + +vi.spyOn(console, 'warn').mockImplementation(() => {}); + +import { authenticate, isAuthenticated, isProtected } from '@/lib/auth'; + +function cookieFrom(setCookie: string): [string, string] { + const [pair] = setCookie.split(';'); + const eq = pair.indexOf('='); + return [pair.slice(0, eq), pair.slice(eq + 1)]; +} + +describe('content page passwords', () => { + it('knows which pages are protected', () => { + expect(isProtected('pricing', 'page')).toBe(true); + expect(isProtected('open', 'page')).toBe(false); + expect(isProtected('missing', 'page')).toBe(false); + }); + + it('sets lb_auth_page_ and accepts it back', async () => { + const setCookie = await authenticate('pricing', 'shared', 'page'); + expect(setCookie).not.toBeNull(); + const [name, value] = cookieFrom(setCookie!); + expect(name).toBe('lb_auth_page_pricing'); + expect(isAuthenticated('pricing', (n) => (n === name ? value : undefined), 'page')).toBe(true); + }); + + it('refuses a wrong password', async () => { + expect(await authenticate('pricing', 'nope', 'page')).toBeNull(); + }); + + it('does not accept a subpage token of the same slug and password', async () => { + const [, subpageValue] = cookieFrom((await authenticate('pricing', 'shared', 'subpage'))!); + expect( + isAuthenticated( + 'pricing', + (n) => (n === 'lb_auth_page_pricing' ? subpageValue : undefined), + 'page', + ), + ).toBe(false); + }); +}); diff --git a/lib/__tests__/auth.test.ts b/lib/__tests__/auth.test.ts index 2c73319..a89bf5b 100644 --- a/lib/__tests__/auth.test.ts +++ b/lib/__tests__/auth.test.ts @@ -25,6 +25,13 @@ vi.mock('@/lib/config', () => ({ albumIds: ['00000000-0000-0000-0000-000000000002'], // no password }, + { + // Taken offline with `enabled: false`, and asks for nothing. + name: 'Offline', + slug: 'offline', + albumIds: ['00000000-0000-0000-0000-000000000006'], + enabled: false, + }, { name: 'Hashed', slug: 'hashed', @@ -34,6 +41,13 @@ vi.mock('@/lib/config', () => ({ password: 'scrypt:0123456789abcdef0123456789abcdef:4850eef18a09612ef11fdcd943bf64ce98d0eca2b0f677f9c0c51d1f648b8a101cf7ea36b6f94cfc05236b2fda6fffabd0929dc7e14c07626522fba68bf26de5', }, + { + // A leftover bcrypt hash: refused outright, whatever the attempt. + name: 'Legacy', + slug: 'legacy', + albumIds: ['00000000-0000-0000-0000-000000000007'], + password: '$2b$10$abcdefghijklmnopqrstuuPjJ7x1b0c9vX8rZ0Yx0mZ1n2o3p4q5r', + }, ], }), })); @@ -129,6 +143,41 @@ describe('password hashing does not stall the process', () => { }); }); +/** + * A key with an scrypt hash took ~50ms to answer, every other key ~2ms — the + * early return skipped hashing. Identical reply bodies did not help: the clock + * alone told a prober which pages, albums and journal entries are locked, + * drafts included. Every attempt now runs exactly one scrypt. Counting the + * calls keeps the test independent of how fast the machine is. + */ +describe('authenticate — one scrypt per attempt, whatever the key', () => { + const attempts: [string, () => Promise][] = [ + ['scrypt hash, right password', () => authenticate('hashed', 'scrypted-pass')], + ['scrypt hash, wrong password', () => authenticate('hashed', 'wrong')], + ['plaintext, right password', () => authenticate('private', 'secret123')], + ['plaintext, wrong password', () => authenticate('private', 'wrong')], + ['key without a password', () => authenticate('public', 'anything')], + ['unknown key', () => authenticate('no-such-page', 'anything')], + ['unknown journal entry', () => authenticate('no-such-entry', 'x', 'journal')], + ['unknown album', () => authenticate('00000000-0000-0000-0000-00000000dead', 'x', 'album')], + ['legacy bcrypt hash', () => authenticate('legacy', 'anything')], + ]; + + it.each(attempts)('%s', async (_label, attempt) => { + const scrypt = vi.spyOn(crypto, 'scrypt'); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await attempt(); + expect(scrypt).toHaveBeenCalledTimes(1); + } finally { + scrypt.mockRestore(); + error.mockRestore(); + warn.mockRestore(); + } + }); +}); + describe('isAuthenticated', () => { it('returns true when a valid cookie is present', async () => { // First authenticate to get the expected token @@ -249,6 +298,11 @@ describe('isAlbumReachable', () => { expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true); }); + // `enabled: false` takes the subpage offline; its albums have no route left. + it('refuses an album whose only subpage is offline', () => { + expect(isAlbumReachable('00000000-0000-0000-0000-000000000006', none)).toBe(false); + }); + it('still refuses an album that carries its own password', async () => { const subpageOnly = await cookieFor('private', 'secret123'); expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false); diff --git a/lib/__tests__/config-cache.test.ts b/lib/__tests__/config-cache.test.ts index 2532d98..fb3b1b2 100644 --- a/lib/__tests__/config-cache.test.ts +++ b/lib/__tests__/config-cache.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; // Mock env.ts so importing config.ts doesn't trigger Zod validation vi.mock('@/lib/env', () => ({ @@ -17,47 +20,109 @@ vi.mock('@/lib/secret', () => ({ resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min', })); -// Replace the filesystem-backed YAML layer. The real loadYaml() already -// revalidates by mtime, so "the file changed on disk" is modelled by the -// mock returning different content. -vi.mock('@/lib/config/parser', () => ({ - loadYaml: vi.fn(), - clearYamlCache: vi.fn(), - validateUuid: (id: string) => id, -})); +// The real YAML layer, spied on: how often it is asked is how often the +// config is derived. +vi.mock('@/lib/config/parser', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadYaml: vi.fn(actual.loadYaml) }; +}); import { getConfig, invalidateConfigCache } from '@/lib/config'; import { loadYaml } from '@/lib/config/parser'; const ALBUM_ID = '11111111-1111-1111-1111-111111111111'; -function mockYamlFiles(settings: Record) { - vi.mocked(loadYaml).mockImplementation((filename: string) => { - if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] }; - if (filename === 'settings.yaml') return settings; - return null; - }); +let dir: string; +let mtime = Date.now() / 1000; + +/** Write a content file with a distinct mtime, the way a save from another worker lands. */ +function write(name: string, body: string) { + const file = path.join(dir, 'content', name); + fs.writeFileSync(file, body); + mtime += 10; + fs.utimesSync(file, mtime, mtime); } describe('getConfig() in production', () => { beforeEach(() => { vi.stubEnv('NODE_ENV', 'production'); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-config-cache-')); + fs.mkdirSync(path.join(dir, 'content')); + vi.spyOn(process, 'cwd').mockReturnValue(dir); + write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n`); + write('settings.yaml', 'title: Old Title\n'); invalidateConfigCache(); + vi.mocked(loadYaml).mockClear(); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); + fs.rmSync(dir, { recursive: true, force: true }); }); it('picks up settings.yaml changes without invalidateConfigCache()', () => { // An admin save may run in a different worker/process than page // rendering, so this worker never sees invalidateConfigCache(). - // getConfig() must delegate freshness to the mtime-checked YAML layer - // instead of short-circuiting on a stale in-memory copy. - mockYamlFiles({ title: 'Old Title' }); + // getConfig() must notice the file changed instead of short-circuiting + // on a stale in-memory copy. expect(getConfig().siteTitle).toBe('Old Title'); - mockYamlFiles({ title: 'New Title' }); + write('settings.yaml', 'title: New Title\n'); expect(getConfig().siteTitle).toBe('New Title'); }); + + it('picks up gallery.yaml changes without invalidateConfigCache()', () => { + expect(getConfig().albums).toEqual([ALBUM_ID]); + + const other = '22222222-2222-2222-2222-222222222222'; + write('gallery.yaml', `albums:\n - "${ALBUM_ID}"\n - "${other}"\n`); + expect(getConfig().albums).toEqual([ALBUM_ID, other]); + }); + + it('notices about.md appearing', () => { + expect(getConfig().aboutEnabled).toBe(false); + + write('about.md', '---\nname: Someone\n---\n'); + expect(getConfig().aboutEnabled).toBe(true); + }); + + it('notices install.json credentials appearing', () => { + // env supplies the URL; the file is still watched. + write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'file-key' })); + getConfig(); + expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2); + + write('install.json', JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'other' })); + getConfig(); + expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4); + }); + + // Every image request and every page render call getConfig() many times + // over. Deriving the whole config each time cost ~150µs a call; the files + // did not change in between. + it('derives the config once while its files are unchanged', () => { + for (let i = 0; i < 100; i++) getConfig(); + + // gallery.yaml and settings.yaml, once — not 200 reads. + expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(2); + }); + + it('derives again after invalidateConfigCache()', () => { + getConfig(); + invalidateConfigCache(); + getConfig(); + + expect(vi.mocked(loadYaml)).toHaveBeenCalledTimes(4); + }); + + it('hands every caller its own copy', () => { + const first = getConfig(); + first.albums.push('mutated'); + first.siteTitle = 'mutated'; + + const second = getConfig(); + expect(second.albums).toEqual([ALBUM_ID]); + expect(second.siteTitle).toBe('Old Title'); + }); }); diff --git a/lib/__tests__/config-default-title.test.ts b/lib/__tests__/config-default-title.test.ts new file mode 100644 index 0000000..35224e8 --- /dev/null +++ b/lib/__tests__/config-default-title.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// No SITE_TITLE, so the title has to come from the locale. +vi.mock('@/lib/env', () => ({ + env: { + IMMICH_API_URL: 'http://localhost:2283', + IMMICH_API_KEY: 'test-key', + SITE_TITLE: '', + SITE_SUBTITLE: '', + CACHE_TTL: 300, + RATE_LIMIT_RPM: 120, + }, + normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''), +})); + +vi.mock('@/lib/secret', () => ({ + resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min', +})); + +vi.mock('@/lib/config/parser', () => ({ + loadYaml: vi.fn(), + clearYamlCache: vi.fn(), + validateUuid: (id: string) => id, +})); + +import { getConfig, invalidateConfigCache } from '@/lib/config'; +import { loadYaml } from '@/lib/config/parser'; + +const ALBUM_ID = '11111111-1111-1111-1111-111111111111'; + +function mockYamlFiles(settings: Record) { + vi.mocked(loadYaml).mockImplementation((filename: string) => { + if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] }; + if (filename === 'settings.yaml') return settings; + return null; + }); +} + +// With neither settings.yaml `title` nor SITE_TITLE set, the site used to be +// called "Gallery" in every language (#696). +describe('default site title', () => { + beforeEach(() => invalidateConfigCache()); + + it('uses the configured locale', () => { + mockYamlFiles({ lang: 'de' }); + expect(getConfig().siteTitle).toBe('Galerie'); + expect(getConfig().seo.title).toBe('Galerie'); + }); + + it('falls back to English for an unsupported locale', () => { + mockYamlFiles({ lang: 'ja' }); + expect(getConfig().siteTitle).toBe('Gallery'); + }); + + it('keeps an explicit title', () => { + mockYamlFiles({ lang: 'de', title: 'Mein Folio' }); + expect(getConfig().siteTitle).toBe('Mein Folio'); + }); +}); diff --git a/lib/__tests__/config-memo-tracing.test.ts b/lib/__tests__/config-memo-tracing.test.ts new file mode 100644 index 0000000..12be4f7 --- /dev/null +++ b/lib/__tests__/config-memo-tracing.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * configInputs() stats a list of paths it builds at runtime. Turbopack cannot + * resolve a path that arrives through a variable, so it traces the whole + * project into `.next/standalone` — app/, docs/, e2e/, the real content files — + * and the Docker image copies all of it (#753 did this, ~70 MB). The ignore + * comment is the documented opt-out; the stat only reads an mtime, and the + * files themselves are loaded (and traced) by loadYaml() and readInstallFile(). + * + * The build prints a warning when this regresses, but nothing fails, so the + * check lives here. + */ + +const source = fs.readFileSync(path.join(__dirname, '..', 'config', 'index.ts'), 'utf8'); + +/** The body of configInputs(), up to the next top-level declaration. */ +const body = source.match(/function configInputs\(\)[\s\S]*?\n}\n/)?.[0] ?? ''; + +describe('config memo key and output file tracing', () => { + it('finds configInputs()', () => { + expect(body).toContain('statSync'); + }); + + it('marks every filesystem call in configInputs() as ignored by Turbopack', () => { + const calls = body.match(/fs\.\w+\([^)]*/g) ?? []; + expect(calls.length).toBeGreaterThan(0); + const unmarked = calls.filter( + (call) => !/^fs\.\w+\(\s*\/\*\s*turbopackIgnore: true\s*\*\//.test(call), + ); + expect(unmarked).toEqual([]); + }); +}); diff --git a/lib/__tests__/content-version.test.ts b/lib/__tests__/content-version.test.ts new file mode 100644 index 0000000..ad33730 --- /dev/null +++ b/lib/__tests__/content-version.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'fs/promises'; +import { + ABSENT_VERSION, + VersionConflictError, + assertVersion, + baseVersionFrom, + fileVersion, + serializeContentWrite, + versionOf, +} from '../admin/contentVersion'; + +/** + * Concurrent-edit detection (#601): a save carrying the version its editor + * loaded is refused when the file changed since; a save without one is + * written as before. + * + * The services resolve content/ from process.cwd() at import time, so each + * test points cwd at a fresh temp directory and re-imports them. + */ +let root: string; +let contentDir: string; + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'folio-version-')); + contentDir = path.join(root, 'content'); + await mkdir(path.join(contentDir, 'journal'), { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(root); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.resetModules(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); +}); + +/** By name: the services are re-imported per test, so their error class is a different copy. */ +const conflict = expect.objectContaining({ name: 'VersionConflictError' }); + +const put = (ifMatch?: string) => + new Request('http://localhost/x', { + method: 'PUT', + headers: ifMatch === undefined ? {} : { 'If-Match': ifMatch }, + }); + +describe('contentVersion helpers', () => { + it('versions content by its bytes, and a missing file as absent', async () => { + expect(versionOf('a')).toBe(versionOf(Buffer.from('a'))); + expect(versionOf('a')).not.toBe(versionOf('b')); + expect(versionOf(null)).toBe(ABSENT_VERSION); + await expect(fileVersion(path.join(root, 'nope'))).resolves.toBe(ABSENT_VERSION); + await writeFile(path.join(root, 'f'), 'a'); + await expect(fileVersion(path.join(root, 'f'))).resolves.toBe(versionOf('a')); + }); + + it('reads If-Match in bare, quoted and weak form; * and absent mean no check', () => { + expect(baseVersionFrom(put('abc'))).toBe('abc'); + expect(baseVersionFrom(put('"abc"'))).toBe('abc'); + expect(baseVersionFrom(put('W/"abc"'))).toBe('abc'); + expect(baseVersionFrom(put('*'))).toBeUndefined(); + expect(baseVersionFrom(put())).toBeUndefined(); + }); + + it('assertVersion passes on a match or no base, and throws with the current version', async () => { + const file = path.join(root, 'f'); + await writeFile(file, 'now'); + await expect(assertVersion(file, versionOf('now'))).resolves.toBeUndefined(); + await expect(assertVersion(file, undefined)).resolves.toBeUndefined(); + const err = await assertVersion(file, versionOf('before')).catch((e) => e); + expect(err).toBeInstanceOf(VersionConflictError); + expect(err.currentVersion).toBe(versionOf('now')); + }); + + it('runs queued writes one at a time, and a failure does not stall the queue', async () => { + const order: string[] = []; + const slow = serializeContentWrite(async () => { + order.push('a:start'); + await new Promise((r) => setTimeout(r, 20)); + order.push('a:end'); + }); + const failing = serializeContentWrite(async () => { + order.push('b'); + throw new Error('boom'); + }); + const after = serializeContentWrite(async () => order.push('c')); + await Promise.allSettled([slow, failing, after]); + await expect(failing).rejects.toThrow('boom'); + expect(order).toEqual(['a:start', 'a:end', 'b', 'c']); + }); +}); + +describe('yaml-service', () => { + it('writes settings.yaml when the base version matches, and refuses when it does not', async () => { + const service = await import('../admin/yaml-service'); + const settingsPath = path.join(contentDir, 'settings.yaml'); + await writeFile(settingsPath, 'title: One\n'); + + const loaded = await service.readSettingsYamlVersioned(); + expect(loaded.data).toEqual({ title: 'One' }); + + // Another tab saves first. + const second = await service.writeSettingsYaml({ title: 'Two' }, loaded.version); + expect(second).toBe(versionOf(await readFile(settingsPath))); + + // This tab still holds the first version. + await expect(service.writeSettingsYaml({ title: 'Three' }, loaded.version)).rejects.toEqual( + expect.objectContaining({ name: 'VersionConflictError', currentVersion: second }), + ); + expect(await service.readSettingsYaml()).toEqual({ title: 'Two' }); + }); + + it('still writes without a base version (older clients)', async () => { + const service = await import('../admin/yaml-service'); + await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n'); + await service.writeGalleryYaml({ hero: ['x'] } as never); + expect(await service.readGalleryYaml()).toEqual({ hero: ['x'] }); + }); + + it('treats a file created since an absent load as a conflict', async () => { + const service = await import('../admin/yaml-service'); + const loaded = await service.readGalleryYamlVersioned(); + expect(loaded.version).toBe(ABSENT_VERSION); + await writeFile(path.join(contentDir, 'gallery.yaml'), 'hero: []\n'); + await expect(service.writeGalleryYaml({ hero: [] } as never, loaded.version)).rejects.toEqual( + conflict, + ); + }); +}); + +const md = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`; + +describe('journal-service saveJournalEntry', () => { + it('saves on a matching version and refuses a stale one', async () => { + const service = await import('../admin/journal-service'); + const file = path.join(contentDir, 'journal', 'trip.md'); + await writeFile(file, md('One')); + const base = versionOf(md('One')); + + await expect(service.saveJournalEntry('trip', md('Two'), { baseVersion: base })).resolves.toBe( + versionOf(md('Two')), + ); + await expect( + service.saveJournalEntry('trip', md('Three'), { baseVersion: base }), + ).rejects.toEqual(conflict); + await expect(readFile(file, 'utf8')).resolves.toBe(md('Two')); + // No base: written as before. + await service.saveJournalEntry('trip', md('Four')); + await expect(readFile(file, 'utf8')).resolves.toBe(md('Four')); + }); + + it('checks a rename against the source entry and leaves both files alone on conflict', async () => { + const service = await import('../admin/journal-service'); + const dir = path.join(contentDir, 'journal'); + await writeFile(path.join(dir, 'old.md'), md('Changed elsewhere')); + + const err = await service + .saveJournalEntry('new', md('Mine'), { + baseVersion: versionOf(md('Loaded')), + fromSlug: 'old', + }) + .catch((e) => e); + expect(err).toEqual(conflict); + expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['old.md']); + + await service.saveJournalEntry('new', md('Mine'), { + baseVersion: versionOf(md('Changed elsewhere')), + fromSlug: 'old', + }); + expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['new.md']); + }); +}); + +describe('pages-service savePage', () => { + it('saves on a matching version, refuses a stale one, and renames', async () => { + const service = await import('../admin/pages-service'); + const dir = path.join(contentDir, 'pages'); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, 'faq.md'), md('FAQ')); + + await expect( + service.savePage('faq', md('Stale'), { baseVersion: versionOf(md('Other')) }), + ).rejects.toEqual(conflict); + await expect(readFile(path.join(dir, 'faq.md'), 'utf8')).resolves.toBe(md('FAQ')); + + await service.savePage('help', md('Help'), { + baseVersion: versionOf(md('FAQ')), + fromSlug: 'faq', + }); + expect((await readdir(dir)).filter((f) => f.endsWith('.md'))).toEqual(['help.md']); + }); +}); diff --git a/lib/__tests__/content-write-guard.test.ts b/lib/__tests__/content-write-guard.test.ts new file mode 100644 index 0000000..3644ace --- /dev/null +++ b/lib/__tests__/content-write-guard.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import fs from 'fs'; +import { writeFile as namedWriteFile, mkdtemp, readFile, rm } from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { guardedContentDir, takeContentWriteViolations } from '../../vitest.content-guard'; + +/** + * The unit suite used to write a real journal entry into the checkout's + * content/ — the live site's directory in a deployment — and leave a + * .deleted.bak behind on every run. vitest.content-guard.ts refuses such + * writes; these tests pin that it catches each way code writes a file, and + * that it leaves reads and temp directories alone. + */ +const repoContent = path.resolve(__dirname, '..', '..', 'content'); +const target = path.join(repoContent, `.content-guard-test-${process.pid}`); + +function expectBlocked(write: () => unknown) { + expect(write).toThrow(/real content\/ directory/); + expect(fs.existsSync(target)).toBe(false); + expect(takeContentWriteViolations()).toHaveLength(1); +} + +async function expectRejected(write: () => Promise) { + await expect(write()).rejects.toThrow(/real content\/ directory/); + expect(fs.existsSync(target)).toBe(false); + expect(takeContentWriteViolations()).toHaveLength(1); +} + +describe('content write guard', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'folio-guard-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + takeContentWriteViolations(); + await rm(tmp, { recursive: true, force: true }); + }); + + it('protects the checkout content/ directory', () => { + expect(guardedContentDir).toBe(repoContent); + }); + + it('rejects fs.promises writes, the named fs/promises import included', async () => { + await expectRejected(() => fs.promises.writeFile(target, 'x')); + await expectRejected(() => namedWriteFile(target, 'x')); + await expectRejected(() => fs.promises.mkdir(target, { recursive: true })); + await expectRejected(() => fs.promises.open(target, 'a+')); + }); + + it('blocks sync, callback and stream writes', () => { + expectBlocked(() => fs.writeFileSync(target, 'x')); + expectBlocked(() => fs.writeFile(target, 'x', () => {})); + expectBlocked(() => fs.mkdirSync(target, { recursive: true })); + expectBlocked(() => fs.appendFileSync(target, 'x')); + expectBlocked(() => fs.createWriteStream(target)); + }); + + it('blocks opening for writing and moving, copying or removing a file', () => { + const outside = path.join(tmp, 'file'); + fs.writeFileSync(outside, 'x'); + expectBlocked(() => fs.openSync(target, 'w')); + expectBlocked(() => fs.renameSync(outside, target)); + expectBlocked(() => fs.copyFileSync(outside, target)); + expectBlocked(() => fs.unlinkSync(target)); + expectBlocked(() => fs.rmSync(target, { force: true })); + }); + + it('still blocks when a test points process.cwd() elsewhere', () => { + vi.spyOn(process, 'cwd').mockReturnValue(tmp); + expectBlocked(() => fs.writeFileSync(target, 'x')); + }); + + it('records a blocked write even when the caller swallows the error', async () => { + await fs.promises.writeFile(target, 'x').catch(() => {}); + expect(takeContentWriteViolations()).toHaveLength(1); + }); + + it('allows reads from content/ and writes anywhere else', async () => { + const example = path.join(repoContent, 'gallery.yaml.example'); + await expect(readFile(example, 'utf8')).resolves.toContain('albums'); + fs.closeSync(fs.openSync(example, 'r')); + + const elsewhere = path.join(tmp, 'content', 'journal', 'entry.md'); + fs.mkdirSync(path.dirname(elsewhere), { recursive: true }); + await namedWriteFile(elsewhere, 'ok'); + await expect(readFile(elsewhere, 'utf8')).resolves.toBe('ok'); + expect(takeContentWriteViolations()).toEqual([]); + }); +}); diff --git a/lib/__tests__/doctor.test.ts b/lib/__tests__/doctor.test.ts index d4391fb..5f51a75 100644 --- a/lib/__tests__/doctor.test.ts +++ b/lib/__tests__/doctor.test.ts @@ -13,6 +13,8 @@ import { checkContact, checkLegal, checkPrivacy, + checkContentPages, + checkSettingValues, worstLevel, } from '../admin/doctor'; @@ -444,3 +446,58 @@ describe('checkPrivacy', () => { ); }); }); + +describe('checkContentPages (#722)', () => { + it('stays silent without pages or references', () => { + expect(checkContentPages({ menuRefs: [], pages: [], collisions: [] })).toBeNull(); + }); + + it('is ok when every reference has a file and no slug is taken', () => { + const f = checkContentPages({ + menuRefs: ['pricing'], + pages: ['pricing', 'faq'], + collisions: [], + }); + expect(f?.level).toBe('ok'); + expect(f?.id).toBe('content-pages'); + }); + + it('warns about a menu reference without a file, naming it', () => { + const f = checkContentPages({ + menuRefs: ['pricing', 'gone'], + pages: ['pricing'], + collisions: [], + }); + expect(f?.level).toBe('warn'); + expect(f?.detail).toContain('"gone"'); + }); + + it('reports a slug collision as an error', () => { + const f = checkContentPages({ + menuRefs: [], + pages: ['travel'], + collisions: [{ slug: 'travel', reason: '/travel is already a subpage.' }], + }); + expect(f?.level).toBe('error'); + expect(f?.detail).toContain('/travel is already a subpage.'); + }); +}); + +describe('checkSettingValues (QA A-14)', () => { + it('is ok when every checked value is used as written', () => { + expect(checkSettingValues([]).level).toBe('ok'); + }); + + it('warns and names the fields the site replaces', () => { + const f = checkSettingValues([{ field: 'theme.accent' }, { field: 'grid.columns' }]); + expect(f.level).toBe('warn'); + expect(f.title).toBe('2 settings are ignored'); + expect(f.detail).toContain('accent colour, grid columns'); + }); + + it('points at the settings section of the first field', () => { + expect(checkSettingValues([{ field: 'url' }]).settingsSection).toBe('seo'); + expect(checkSettingValues([{ field: 'grid.gap' }]).settingsSection).toBe('grid'); + expect(checkSettingValues([{ field: 'theme.accent' }]).settingsSection).toBe('theme'); + }); +}); diff --git a/lib/__tests__/envLocks.test.ts b/lib/__tests__/envLocks.test.ts new file mode 100644 index 0000000..931c540 --- /dev/null +++ b/lib/__tests__/envLocks.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { ENV_LOCKS, keepLockedValues, resolveEnvLocks } from '../admin/envLocks'; + +describe('resolveEnvLocks (#605)', () => { + it('locks nothing when no overriding variable is set', () => { + expect(resolveEnvLocks({})).toEqual({}); + expect(resolveEnvLocks({ SITE_PASSWORD: ' ', CONTACT_NOTIFY_URL: undefined })).toEqual({}); + }); + + it('maps each set variable to its settings path, by name only', () => { + const locks = resolveEnvLocks({ + SITE_PASSWORD: 'hunter2', + CONTACT_NOTIFY_URL: 'https://ntfy.sh/secret-topic', + }); + expect(locks).toEqual({ + sitePassword: 'SITE_PASSWORD', + 'contact.notifyUrl': 'CONTACT_NOTIFY_URL', + }); + expect(JSON.stringify(locks)).not.toContain('hunter2'); + expect(JSON.stringify(locks)).not.toContain('secret-topic'); + }); + + it('does not lock title, subtitle or url: settings.yaml wins over those variables', () => { + const locks = resolveEnvLocks({ SITE_TITLE: 'T', SITE_SUBTITLE: 'S', SITE_URL: 'https://x' }); + expect(locks).toEqual({}); + expect(Object.keys(ENV_LOCKS)).not.toContain('title'); + }); +}); + +describe('keepLockedValues (#605)', () => { + const locks = { sitePassword: 'SITE_PASSWORD', 'contact.notifyUrl': 'CONTACT_NOTIFY_URL' }; + + it('puts the stored value back over an edited locked field', () => { + const out = keepLockedValues( + { title: 'New', sitePassword: 'changed', contact: { enabled: true, notifyUrl: 'https://b' } }, + { sitePassword: 'scrypt:a:b', contact: { notifyUrl: 'https://a' } }, + locks, + ); + expect(out).toEqual({ + title: 'New', + sitePassword: 'scrypt:a:b', + contact: { enabled: true, notifyUrl: 'https://a' }, + }); + }); + + it('drops a locked value the file never had', () => { + const out = keepLockedValues( + { sitePassword: 'new', contact: { notifyUrl: 'https://b' } }, + {}, + locks, + ); + expect(out).toEqual({ contact: {} }); + }); + + it('leaves unlocked fields alone and does not mutate the input', () => { + const incoming = { sitePassword: 'new' }; + expect(keepLockedValues(incoming, { sitePassword: 'old' }, {})).toEqual({ + sitePassword: 'new', + }); + keepLockedValues(incoming, { sitePassword: 'old' }, locks); + expect(incoming.sitePassword).toBe('new'); + }); +}); diff --git a/lib/__tests__/essay-source.test.ts b/lib/__tests__/essay-source.test.ts index e0092ec..f097f54 100644 --- a/lib/__tests__/essay-source.test.ts +++ b/lib/__tests__/essay-source.test.ts @@ -62,7 +62,7 @@ describe('resolveEssayFile', () => { }); it('locks a password-protected entry without a cookie', () => { - expect(resolveEssayFile('locked', anonymous)).toEqual({ status: 'locked', title: 'Locked' }); + expect(resolveEssayFile('locked', anonymous)).toEqual({ status: 'locked' }); }); it('keeps the entry locked for the admin, like /journal/ does', () => { diff --git a/lib/__tests__/i18n.test.ts b/lib/__tests__/i18n.test.ts index 6a91553..f287795 100644 --- a/lib/__tests__/i18n.test.ts +++ b/lib/__tests__/i18n.test.ts @@ -219,3 +219,160 @@ describe('plural helpers', () => { }, ); }); + +/** + * Count strings at the CLDR boundaries. French puts 0 in the singular ("0 photo"), + * the other five use the singular for exactly 1 — so a shared `n === 1` helper + * was wrong for French, and a hard-coded plural noun ("(1 items)") was wrong for + * everyone at 1. Italian "foto"/"album" and the Dutch/German participles are + * invariable, which the table spells out rather than skips. + */ +type Counted = (d: Dictionary, n: number) => string; +const COUNTED = { + photos: (d, n) => d.common.photos(n), + albums: (d, n) => d.common.albums(n), + collections: (d, n) => d.common.collections(n), + selected: (d, n) => d.proofing.selected(n), + mailSubject: (d, n) => d.proofing.mailSubject(n), + downloadsLeft: (d, n) => d.proofSession.downloadsLeft(n), +} satisfies Record; +type CountedKey = keyof typeof COUNTED; + +const PLURAL_TABLE: [string, Dictionary, Record][] = [ + [ + 'en', + en, + { + photos: ['0 photos', '1 photo', '2 photos'], + albums: ['0 albums', '1 album', '2 albums'], + collections: ['0 collections', '1 collection', '2 collections'], + selected: ['❤️ 0 Selected', '❤️ 1 Selected', '❤️ 2 Selected'], + mailSubject: [ + 'Photo Selection (0 items)', + 'Photo Selection (1 item)', + 'Photo Selection (2 items)', + ], + downloadsLeft: ['0 downloads left', '1 download left', '2 downloads left'], + }, + ], + [ + 'de', + de, + { + photos: ['0 Fotos', '1 Foto', '2 Fotos'], + albums: ['0 Alben', '1 Album', '2 Alben'], + collections: ['0 Sammlungen', '1 Sammlung', '2 Sammlungen'], + selected: ['❤️ 0 ausgewählt', '❤️ 1 ausgewählt', '❤️ 2 ausgewählt'], + mailSubject: ['Fotoauswahl (0 Fotos)', 'Fotoauswahl (1 Foto)', 'Fotoauswahl (2 Fotos)'], + downloadsLeft: ['Noch 0 Downloads', 'Noch 1 Download', 'Noch 2 Downloads'], + }, + ], + [ + 'fr', + fr, + { + photos: ['0 photo', '1 photo', '2 photos'], + albums: ['0 album', '1 album', '2 albums'], + collections: ['0 collection', '1 collection', '2 collections'], + selected: ['❤️ 0 sélectionnée', '❤️ 1 sélectionnée', '❤️ 2 sélectionnées'], + mailSubject: [ + 'Sélection de photos (0)', + 'Sélection de photos (1)', + 'Sélection de photos (2)', + ], + downloadsLeft: [ + '0 téléchargement restant', + '1 téléchargement restant', + '2 téléchargements restants', + ], + }, + ], + [ + 'es', + es, + { + photos: ['0 fotos', '1 foto', '2 fotos'], + albums: ['0 álbumes', '1 álbum', '2 álbumes'], + collections: ['0 colecciones', '1 colección', '2 colecciones'], + selected: ['❤️ 0 seleccionadas', '❤️ 1 seleccionada', '❤️ 2 seleccionadas'], + mailSubject: ['Selección de fotos (0)', 'Selección de fotos (1)', 'Selección de fotos (2)'], + downloadsLeft: ['0 descargas restantes', '1 descarga restante', '2 descargas restantes'], + }, + ], + [ + 'it', + itIT, + { + photos: ['0 foto', '1 foto', '2 foto'], + albums: ['0 album', '1 album', '2 album'], + collections: ['0 collezioni', '1 collezione', '2 collezioni'], + selected: ['❤️ 0 selezionate', '❤️ 1 selezionata', '❤️ 2 selezionate'], + mailSubject: ['Selezione di foto (0)', 'Selezione di foto (1)', 'Selezione di foto (2)'], + downloadsLeft: ['0 download rimasti', '1 download rimasto', '2 download rimasti'], + }, + ], + [ + 'nl', + nl, + { + photos: ['0 foto’s', '1 foto', '2 foto’s'], + albums: ['0 albums', '1 album', '2 albums'], + collections: ['0 collecties', '1 collectie', '2 collecties'], + selected: ['❤️ 0 geselecteerd', '❤️ 1 geselecteerd', '❤️ 2 geselecteerd'], + mailSubject: ['Fotoselectie (0)', 'Fotoselectie (1)', 'Fotoselectie (2)'], + downloadsLeft: ['Nog 0 downloads', 'Nog 1 download', 'Nog 2 downloads'], + }, + ], +]; + +describe('plural forms at 0, 1 and 2', () => { + it('cover every supported locale', () => { + expect(PLURAL_TABLE.map(([code]) => code).sort()).toEqual([...SUPPORTED_LOCALES].sort()); + }); + + describe.each(PLURAL_TABLE)('%s', (_code, dict, expected) => { + it.each(Object.keys(COUNTED) as CountedKey[])('%s', (key) => { + expect([0, 1, 2].map((n) => COUNTED[key](dict, n))).toEqual(expected[key]); + }); + }); + + it.each([ + ['en', en, ['0 days.', '1 day.', '2 days.']], + ['de', de, ['0 Tagen gelöscht.', '1 Tag gelöscht.', '2 Tagen gelöscht.']], + ['fr', fr, ['0 jour.', '1 jour.', '2 jours.']], + ['es', es, ['0 días.', '1 día.', '2 días.']], + ['it', itIT, ['0 giorni.', '1 giorno.', '2 giorni.']], + ['nl', nl, ['0 dagen verwijderd.', '1 dag verwijderd.', '2 dagen verwijderd.']], + ] as const)('contact retention days in %s', (_code, dict, endings) => { + const got = [0, 1, 2].map((n) => dict.contact.privacy(n)); + got.forEach((text, i) => expect(text.endsWith(` ${endings[i]}`), text).toBe(true)); + }); +}); + +describe('German terminology', () => { + // The carousel said "Diashow", the lightbox's shortcut panel and live + // region "Diaschau" for the same feature. + it('calls the slideshow "Diashow" everywhere', () => { + const strings = [ + de.home.pauseSlideshow, + de.home.playSlideshow, + de.lightbox.shortcutSlideshow, + de.lightbox.shortcutSlideshowRunning(5), + de.lightbox.slideshowStopped, + de.lightbox.slideshowRunning(5), + ]; + for (const s of strings) expect(s).toContain('Diashow'); + }); + + it('leaves no "Diaschau" in any string', () => { + const walk = (value: unknown): string[] => + typeof value === 'string' + ? [value] + : typeof value === 'function' + ? [String((value as (n: number) => string)(2))] + : value && typeof value === 'object' + ? Object.values(value).flatMap(walk) + : []; + expect(walk(de).filter((s) => s.includes('Diaschau'))).toEqual([]); + }); +}); diff --git a/lib/__tests__/image-url-per-tier.test.ts b/lib/__tests__/image-url-per-tier.test.ts new file mode 100644 index 0000000..e161387 --- /dev/null +++ b/lib/__tests__/image-url-per-tier.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { getImgProps } from 'next/dist/shared/lib/get-img-props'; +import { imageConfigDefault } from 'next/dist/shared/lib/image-config'; +import immichLoader from '../immichLoader'; +import { canonicalImageUrl, resolveImageSize } from '../imageSize'; + +/** + * The proxy answers every width inside an Immich tier with the same bytes, but + * each distinct URL is its own `immutable` cache entry. next/image used to + * write a different `?w=` into every srcset candidate and the lightbox asked + * for the bare URL, so a grid tile and the same photo in the lightbox — or a + * tile before and after a rotation — downloaded the identical preview twice. + */ + +const PREVIEW = '/api/image/v2:tok?size=preview'; +const THUMB = '/api/image/v2:tok?size=thumbnail'; + +const ALL_WIDTHS = [...imageConfigDefault.deviceSizes, ...imageConfigDefault.imageSizes]; + +function srcSetUrls(src: string, sizes: string): string[] { + const { props } = getImgProps( + { src, alt: '', fill: true, sizes }, + { + defaultLoader: immichLoader, + imgConf: { ...imageConfigDefault, loader: 'custom', loaderFile: './lib/immichLoader.ts' }, + showAltText: false, + blurComplete: false, + }, + ); + return (props.srcSet ?? '').split(', ').map((candidate) => candidate.split(' ')[0]); +} + +const tierOf = (url: string) => { + const params = new URLSearchParams(url.slice(url.indexOf('?') + 1)); + return resolveImageSize(params.get('size'), params.get('w')); +}; + +describe('one image URL per asset and tier', () => { + it.each([ + ['PhotoGrid', '(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw'], + ['SubpageGridView', '(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw'], + ['album hero', '100vw'], + ['home hero', '(max-width: 640px) 100vw, 50vw'], + ['essay', '(max-width: 1100px) 100vw, 1100px'], + ])('every %s srcset candidate is the URL the lightbox opens', (_name, sizes) => { + const urls = srcSetUrls(PREVIEW, sizes); + expect(urls.length).toBeGreaterThan(3); + expect(new Set(urls)).toEqual(new Set([canonicalImageUrl(PREVIEW)])); + }); + + it('collapses a thumbnail-ceiling image to one URL as well', () => { + expect(new Set(srcSetUrls(THUMB, '25vw'))).toEqual(new Set([canonicalImageUrl(THUMB)])); + }); + + it('asks the proxy for the same rendition the raw width did', () => { + for (const size of ['thumbnail', 'preview', 'original', null]) { + const src = size ? `/api/image/v2:tok?size=${size}` : '/api/image/v2:tok'; + for (const w of [...ALL_WIDTHS, 1, 250, 251, 1440, 1441, 99999]) { + expect(tierOf(canonicalImageUrl(src, w))).toBe(resolveImageSize(size, String(w))); + } + } + }); + + it('keeps the cache buster and writes a width next/image accepts', () => { + expect(canonicalImageUrl('/api/image/v2:tok?size=preview&v=7', 640)).toBe( + '/api/image/v2:tok?size=preview&v=7&w=1440', + ); + expect(canonicalImageUrl('https://cdn.example.com/api/image/v2:tok?size=thumbnail', 96)).toBe( + 'https://cdn.example.com/api/image/v2:tok?size=thumbnail&w=250', + ); + expect(immichLoader({ src: PREVIEW, width: 400 })).not.toBe(PREVIEW); + }); +}); diff --git a/lib/__tests__/immich-follows-content.test.ts b/lib/__tests__/immich-follows-content.test.ts new file mode 100644 index 0000000..7c38a9b --- /dev/null +++ b/lib/__tests__/immich-follows-content.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Public pages follow admin saves (A-2) and the page builder's album order + * (A-6). + * + * An admin save lands in another module instance (Next bundles each route + * separately), so its immich.invalidateAll() never reached the cache the + * pages read. The album list was cached with the allowlist and the + * gallery.yaml titles and descriptions already applied, and the public site + * kept serving the old ones for up to CACHE_TTL. These tests use the real + * getConfig() on a temp content directory: the only signal a save leaves is + * the new mtime of gallery.yaml. + */ + +vi.mock('@/lib/env', () => ({ + env: { + IMMICH_API_URL: 'http://immich.test', + IMMICH_API_KEY: 'test-key', + SITE_TITLE: 'Test Gallery', + SITE_SUBTITLE: '', + CACHE_TTL: 300, + STALE_MAX_AGE: 86_400, + IMMICH_TIMEOUT_MS: 15_000, + RATE_LIMIT_RPM: 120, + }, + normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''), +})); + +vi.mock('@/lib/secret', () => ({ + resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min', +})); + +import { invalidateConfigCache } from '@/lib/config'; +import { cache } from '@/lib/cache'; +import { immich } from '@/lib/immich'; + +const A = '11111111-1111-4111-8111-111111111111'; +const B = '22222222-2222-4222-8222-222222222222'; +const C = '33333333-3333-4333-8333-333333333333'; +const NEW = '44444444-4444-4444-8444-444444444444'; + +let dir: string; +let mtime = Date.now() / 1000; +let immichAlbums: { id: string; albumName: string; description: string; assetCount: number }[]; +const fetchMock = vi.fn(); + +/** Write a content file with a distinct mtime, the way a save from another instance lands. */ +function write(name: string, body: string) { + const file = path.join(dir, 'content', name); + fs.writeFileSync(file, body); + mtime += 10; + fs.utimesSync(file, mtime, mtime); +} + +const json = (body: unknown) => ({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => body, +}); + +const listRequests = () => + fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/albums?shared=true')).length; + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'production'); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-immich-follows-')); + fs.mkdirSync(path.join(dir, 'content')); + vi.spyOn(process, 'cwd').mockReturnValue(dir); + write('settings.yaml', 'title: Test\n'); + invalidateConfigCache(); + cache.clear(); + immichAlbums = [ + // Immich answers in its own order (last updated first, say). + { id: C, albumName: 'Gamma', description: 'from Immich', assetCount: 3 }, + { id: A, albumName: 'Alpha', description: '', assetCount: 1 }, + { id: B, albumName: 'Beta', description: '', assetCount: 2 }, + ]; + fetchMock.mockReset(); + fetchMock.mockImplementation(async (url: string) => { + if (String(url).endsWith('/albums?shared=true')) return json(immichAlbums); + return { ok: false, status: 404, headers: { get: () => 'application/json' } }; + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('public album data follows a gallery.yaml save (A-2)', () => { + it('shows a new title and description on the next call, without asking Immich again', async () => { + write('gallery.yaml', `albums:\n - "${A}"\n - "${B}"\n`); + const before = await immich.getAlbums(); + expect(before.map((a) => a.albumName)).toEqual(['Alpha', 'Beta']); + expect(listRequests()).toBe(1); + + // The admin saves in another module instance: no invalidation reaches us. + write( + 'gallery.yaml', + `albums:\n - "${A}":\n title: "Alpha, renamed"\n description: "Now described"\n - "${B}"\n`, + ); + + const after = await immich.getAlbums(); + const alpha = after.find((a) => a.id === A)!; + expect(alpha.albumName).toBe('Alpha, renamed'); + expect(alpha.description).toBe('Now described'); + expect(alpha.slug).toBe('alpha-renamed'); + expect(listRequests()).toBe(1); + }); + + it('drops an unpublished album and adds a newly published one at once', async () => { + write('gallery.yaml', `albums:\n - "${A}"\n - "${B}"\n`); + expect((await immich.getAlbums()).map((a) => a.id)).toEqual([A, B]); + + write('gallery.yaml', `albums:\n - "${B}"\n - "${C}"\n`); + expect((await immich.getAlbums()).map((a) => a.id).sort()).toEqual([B, C].sort()); + expect(listRequests()).toBe(1); + }); + + it('refetches the list once for an album created in Immich after it was cached', async () => { + write('gallery.yaml', `albums:\n - "${A}"\n`); + await immich.getAlbums(); + + immichAlbums = [ + ...immichAlbums, + { id: NEW, albumName: 'Fresh', description: '', assetCount: 1 }, + ]; + write('gallery.yaml', `albums:\n - "${A}"\n - "${NEW}"\n`); + expect((await immich.getAlbums()).map((a) => a.id)).toContain(NEW); + expect(listRequests()).toBe(2); + + // Cached again, and an allowlisted album Immich does not know (deleted) + // costs no request per call. + write( + 'gallery.yaml', + `albums:\n - "${A}"\n - "${NEW}"\n - "${'5'.repeat(8)}-5555-4555-8555-555555555555"\n`, + ); + await immich.getAlbums(); + await immich.getAlbums(); + await immich.getAlbums(); + expect(listRequests()).toBe(3); + }); + + it('applies overrides to a single album from the cache as well', async () => { + fetchMock.mockImplementation(async (url: string) => { + if (String(url).includes('/search/metadata')) { + return json({ assets: { items: [], nextPage: null } }); + } + if (String(url).endsWith(`/albums/${A}`)) { + return json({ id: A, albumName: 'Alpha', description: '', assetCount: 0, order: 'desc' }); + } + return json(immichAlbums); + }); + write('gallery.yaml', `albums:\n - "${A}"\n`); + expect((await immich.getAlbum(A))?.albumName).toBe('Alpha'); + const albumRequests = () => + fetchMock.mock.calls.filter(([url]) => String(url).endsWith(`/albums/${A}`)).length; + expect(albumRequests()).toBe(1); + + write('gallery.yaml', `albums:\n - "${A}": Alpha Prime\n`); + const album = await immich.getAlbum(A); + expect(album?.albumName).toBe('Alpha Prime'); + expect(album?.slug).toBe('alpha-prime'); + expect(albumRequests()).toBe(1); + }); +}); + +describe('subpage albums follow the configured order (A-6)', () => { + it('lists albums in gallery.yaml order, not Immich order', async () => { + write( + 'gallery.yaml', + `subpages:\n - name: Trips\n albums:\n - "${B}"\n - "${A}"\n - "${C}"\n`, + ); + const result = await immich.getSubpageAlbums('trips'); + expect(result?.albums.map((a) => a.id)).toEqual([B, A, C]); + + // Rearranged in the page builder: the next call follows. + write( + 'gallery.yaml', + `subpages:\n - name: Trips\n albums:\n - "${C}"\n - "${B}"\n - "${A}"\n`, + ); + const reordered = await immich.getSubpageAlbums('trips'); + expect(reordered?.albums.map((a) => a.id)).toEqual([C, B, A]); + expect(listRequests()).toBe(1); + }); + + it('keeps section order across sections', async () => { + write( + 'gallery.yaml', + [ + 'subpages:', + ' - name: Japan', + ' sections:', + ' - title: South', + ' albums:', + ` - "${B}"`, + ' - title: North', + ' albums:', + ` - "${C}"`, + ` - "${A}"`, + '', + ].join('\n'), + ); + const result = await immich.getSubpageAlbums('japan'); + expect(result?.albums.map((a) => a.id)).toEqual([B, C, A]); + }); +}); + +describe('the cache is one per process, not one per module instance', () => { + it('is cleared for every instance by a clear in any of them', async () => { + vi.resetModules(); + const other = await import('@/lib/cache'); + expect(other.cache).not.toBe(cache); + + cache.set('probe', 1, 60_000); + expect(other.cache.get('probe')).toBe(1); + expect(other.cache.size).toBe(cache.size); + + other.cache.clear(); + expect(cache.get('probe')).toBeNull(); + }); +}); diff --git a/lib/__tests__/immich-setup-state.test.ts b/lib/__tests__/immich-setup-state.test.ts index ab1dff0..646a31d 100644 --- a/lib/__tests__/immich-setup-state.test.ts +++ b/lib/__tests__/immich-setup-state.test.ts @@ -80,9 +80,11 @@ describe('ImmichClient — install state', () => { * The setup config carries an empty allowlist, so the filtered list is [] no * matter what Immich returned. Caching that made the gallery look empty until * the server restarted, because the wizard's invalidateAll() runs in another - * bundle's module instance and never reached this cache. + * bundle's module instance and never reached this cache. The cache now holds + * Immich's list as sent and the allowlist is applied per call, so the album + * shows up from the cached list without a second request. */ - it('does not cache the empty album list while setup is unfinished', async () => { + it('does not keep the empty album list of the setup config after setup', async () => { const album = { id: 'album-1', albumName: 'Japan', @@ -106,10 +108,9 @@ describe('ImmichClient — install state', () => { state.needsGallery = false; state.albums = ['album-1']; - mockFetch.mockResolvedValueOnce(respond()); const after = await immich.getAlbums(); expect(after.map((a) => a.id)).toEqual(['album-1']); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(1); }); it('makes no request at all when credentials are absent', async () => { diff --git a/lib/__tests__/immich.test.ts b/lib/__tests__/immich.test.ts index afc5eaf..2f08792 100644 --- a/lib/__tests__/immich.test.ts +++ b/lib/__tests__/immich.test.ts @@ -16,7 +16,8 @@ vi.mock('../config', async () => { const actual = await vi.importActual('../config'); return { ...actual, - getConfig: () => ({ + // A spy, so the tests below can count how often the client reads it. + getConfig: vi.fn(() => ({ immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' }, authSecret: 'test-auth-secret-32-chars-long-min', albums: ['album-1', 'album-2'], @@ -34,7 +35,7 @@ vi.mock('../config', async () => { cacheTtl: 60_000, staleMaxAge: 86_400_000, immichTimeoutMs: 15000, - }), + })), }; }); @@ -567,6 +568,59 @@ describe('ImmichClient', () => { }); }); + /** + * getConfig() re-checks the content files and clones the whole config on + * every call, and the client used to call it once per property it read — + * four times per streamed image, twice per album on a list refresh. + */ + describe('reads the config once per operation', () => { + const configReads = () => vi.mocked(config.getConfig).mock.calls.length; + const json = (body: unknown) => ({ + ok: true, + headers: { get: () => 'application/json' }, + json: async () => body, + }); + + it('streamAsset(): once per image', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + body: new ReadableStream(), + headers: { get: () => 'image/jpeg' }, + }); + + await immich.streamAsset('asset-1', 'preview'); + + expect(configReads()).toBe(1); + }); + + it('getAlbums(): twice on a refresh, however many albums there are', async () => { + const album = (id: string) => ({ id, albumName: id, assetCount: 1, assets: [] }); + mockFetch.mockResolvedValueOnce(json([album('album-1'), album('album-2')])); + + expect(await immich.getAlbums()).toHaveLength(2); + + // One for the request, one for filtering, naming and caching the list. + expect(configReads()).toBe(2); + }); + + it('getAlbum(): twice on a cache hit', async () => { + mockFetch.mockImplementation(async (url: string) => + json( + url.includes('/search/metadata') + ? { assets: { items: [], nextPage: null } } + : { id: 'album-1', albumName: 'A', assetCount: 0, assets: [], order: 'desc' }, + ), + ); + await immich.getAlbum('album-1'); + vi.mocked(config.getConfig).mockClear(); + + await immich.getAlbum('album-1'); + + // The allowlist check, then the configured sort. + expect(configReads()).toBe(2); + }); + }); + describe('getAlbum()', () => { it('returns null for unallowed album IDs', async () => { const album = await immich.getAlbum('album-3'); @@ -821,6 +875,85 @@ describe('ImmichClient', () => { }); }); }); + + /** + * Album blocks in journal entries, content pages and essay files are + * rendered from getAlbumAssetsRaw(), and so is the published-asset set. It + * used to go to Immich on every call — two requests per album per page view, + * and again on every set rebuild — with no cache and no coalescing. + */ + describe('getAlbumAssetsRaw() request count', () => { + function mockRawAlbumApi(albumId: string) { + mockFetch.mockImplementation(async (url: string) => { + const body = url.includes('/search/metadata') + ? { + assets: { + items: [ + { id: 'old', isTrashed: false, fileCreatedAt: '2024-01-01T00:00:00Z' }, + { id: 'gone', isTrashed: true, fileCreatedAt: '2024-01-02T00:00:00Z' }, + { id: 'new', isTrashed: false, fileCreatedAt: '2024-01-03T00:00:00Z' }, + ], + nextPage: null, + }, + } + : { id: albumId, albumName: 'Raw', assetCount: 3, assets: [], order: 'desc' }; + return { ok: true, headers: { get: () => 'application/json' }, json: async () => body }; + }); + } + + it('asks Immich once for many concurrent callers and serves repeats from the cache', async () => { + mockRawAlbumApi('unlisted'); + + const lists = await Promise.all( + Array.from({ length: 10 }, () => immich.getAlbumAssetsRaw('unlisted')), + ); + // One album request plus one metadata page — not 2 × 10. + expect(mockFetch).toHaveBeenCalledTimes(2); + + await immich.getAlbumAssetsRaw('unlisted'); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Unchanged result: trashed dropped, capture-time order of a 'desc' album. + for (const list of lists) expect(list.map((a) => a.id)).toEqual(['new', 'old']); + }); + + it('shares the load with getAlbum() for an allowlisted album', async () => { + mockRawAlbumApi('album-1'); + + await immich.getAlbum('album-1'); + const raw = await immich.getAlbumAssetsRaw('album-1'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(raw.map((a) => a.id)).toEqual(['new', 'old']); + }); + + it('hands each caller its own array, so no one reorders the cached entry', async () => { + mockRawAlbumApi('unlisted'); + + const first = await immich.getAlbumAssetsRaw('unlisted'); + first.reverse(); + + expect((await immich.getAlbumAssetsRaw('unlisted')).map((a) => a.id)).toEqual(['new', 'old']); + }); + + it('goes back to Immich when the admin editors ask for a fresh copy', async () => { + mockRawAlbumApi('unlisted'); + + await immich.getAlbumAssetsRaw('unlisted'); + await immich.getAlbumAssetsRaw('unlisted', true); + + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it('still does not open getAlbum() to an album outside the allowlist', async () => { + mockRawAlbumApi('unlisted'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await immich.getAlbumAssetsRaw('unlisted'); + + expect(await immich.getAlbum('unlisted')).toBeNull(); + }); + }); }); describe('stale fallback when Immich is unavailable', () => { diff --git a/lib/__tests__/journal-order.test.ts b/lib/__tests__/journal-order.test.ts new file mode 100644 index 0000000..1362a3c --- /dev/null +++ b/lib/__tests__/journal-order.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; + +/** + * listJournalEntries sorted by date only, so two entries from the same day kept + * whatever order readdir returned — the /journal index and the entry page's + * prev/next links then depended on the filesystem. The slug breaks the tie. + */ +let root: string; +let journalDir: string; +let service: typeof import('../admin/journal-service'); + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'folio-journal-order-')); + journalDir = path.join(root, 'content', 'journal'); + await fs.mkdir(journalDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(root); + vi.resetModules(); + service = await import('../admin/journal-service'); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(root, { recursive: true, force: true }); +}); + +const entry = (title: string, date?: string) => + `---\ntitle: "${title}"\n${date ? `date: "${date}"\n` : ''}---\n\nBody\n`; + +/** Make readdir hand the files back in a fixed order. */ +function readdirOrder(order: 'asc' | 'desc') { + const real = fs.readdir.bind(fs) as (dir: string) => Promise; + vi.spyOn(fs, 'readdir').mockImplementation((async (dir: string) => { + const names = [...(await real(dir))].sort(); + return order === 'asc' ? names : names.reverse(); + }) as unknown as typeof fs.readdir); +} + +describe('listJournalEntries order', () => { + it.each(['asc', 'desc'] as const)( + 'orders same-day entries by slug whatever readdir returns (%s)', + async (order) => { + await fs.writeFile(path.join(journalDir, 'alpha.md'), entry('Zebra', '2026-03-15')); + await fs.writeFile(path.join(journalDir, 'beta.md'), entry('Aardvark', '2026-03-15')); + await fs.writeFile(path.join(journalDir, 'newer.md'), entry('Newer', '2026-04-01')); + readdirOrder(order); + + const slugs = (await service.listJournalEntries()).map((e) => e.slug); + expect(slugs).toEqual(['newer', 'alpha', 'beta']); + }, + ); + + it.each(['asc', 'desc'] as const)( + 'orders undated entries with the same title by slug (%s)', + async (order) => { + await fs.writeFile(path.join(journalDir, 'one.md'), entry('Untitled')); + await fs.writeFile(path.join(journalDir, 'two.md'), entry('Untitled')); + readdirOrder(order); + + const slugs = (await service.listJournalEntries()).map((e) => e.slug); + expect(slugs).toEqual(['one', 'two']); + }, + ); +}); diff --git a/lib/__tests__/journal-photo-caption.test.ts b/lib/__tests__/journal-photo-caption.test.ts new file mode 100644 index 0000000..031a87e --- /dev/null +++ b/lib/__tests__/journal-photo-caption.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect } from 'vitest'; +import { + calculateReadingTime, + parseJournalMarkdown, + renderInlineMarkdown, + serializeJournalMarkdown, +} from '../journal'; +import type { JournalBlock } from '../journal'; + +const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d'; +const ID2 = '7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d'; +const ID3 = 'b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e'; + +const serialize = (blocks: JournalBlock[]) => + serializeJournalMarkdown({ frontmatter: {}, blocks, referencedAssetIds: [] }); +const roundTrip = (source: string) => serializeJournalMarkdown(parseJournalMarkdown(source)); + +/** No block that renders as text may carry an asset id. */ +function expectNoIdInText(blocks: JournalBlock[], ids: string[]) { + for (const block of blocks) { + const text = + block.type === 'paragraph' + ? block.html + : block.type === 'heading' + ? block.text + : block.type === 'quote' + ? block.text + : 'caption' in block + ? (block.caption ?? '') + : ''; + for (const id of ids) expect(text).not.toContain(id); + } +} + +/** Captions that broke, or could break, the `![…](…)` framing. */ +const TRICKY_CAPTIONS = [ + 'Gipfel (560 m)', + '(560 m)', + 'Ende :)', + 'Anfang :(', + '((doppelt))', + ')', + '(', + 'a) b (c', + 'Siehe [Karte](https://example.com/map_(1)) oben', + '[Link](https://immich.app)', + '**fett (mit Klammer)** und *kursiv*', + 'eckige [Klammern] ohne Link', + '] mitten [', + 'Pfad C:\\Fotos\\', + 'Backslash vor Klammer \\) und \\(', + 'Emoji 🏔️ (Gipfel) 📷', + 'Umlaute: Größe, Übersicht, Äpfel (Öl)', + 'Anna\'s "Zitat" & ', + '![verschachtelt](nicht wirklich)', +]; + +describe('photo captions with parentheses (A-1)', () => { + it('keeps a wide photo with `(560 m)` in its caption a photo block', () => { + const source = `![${ID}:wide](Gipfel (560 m))`; + const parsed = parseJournalMarkdown(source); + expect(parsed.blocks).toEqual([ + { type: 'photo', assetId: ID, layout: 'wide', caption: 'Gipfel (560 m)' }, + ]); + expect(roundTrip(source)).toBe(source); + }); + + it('keeps pair and grid captions with parentheses intact', () => { + const pair = `![${ID}, ${ID2}](Links (Nord), rechts (Süd))`; + expect(parseJournalMarkdown(pair).blocks[0]).toEqual({ + type: 'photo-pair', + assetIds: [ID, ID2], + caption: 'Links (Nord), rechts (Süd)', + }); + expect(roundTrip(pair)).toBe(pair); + + const grid = `![${ID}, ${ID2}, ${ID3}](Drei (3))`; + expect(parseJournalMarkdown(grid).blocks[0]).toEqual({ + type: 'photo-grid', + assetIds: [ID, ID2, ID3], + caption: 'Drei (3)', + }); + expect(roundTrip(grid)).toBe(grid); + }); + + it('renders a link inside a caption', () => { + const parsed = parseJournalMarkdown(`![${ID}](Mehr auf [Immich](https://immich.app))`); + expect(parsed.blocks[0]).toMatchObject({ + type: 'photo', + caption: + 'Mehr auf Immich', + }); + }); + + it('keeps balanced parentheses inside a link URL', () => { + const source = `![${ID}:wide](Gipfel (560 m), siehe [Karte](https://example.com/map_(1)))`; + const parsed = parseJournalMarkdown(source); + expect(parsed.blocks[0]).toMatchObject({ + type: 'photo', + caption: + 'Gipfel (560 m), siehe Karte', + }); + expect(roundTrip(source)).toBe(source); + // Outside a link, a parenthesis after the URL is still text. + expect(renderInlineMarkdown('[a](https://x.y) (b)')).toBe( + 'a (b)', + ); + }); + + for (const caption of TRICKY_CAPTIONS) { + it(`round-trips the caption ${JSON.stringify(caption)} for every photo block type`, () => { + const html = renderInlineMarkdown(caption); + const blocks: JournalBlock[] = [ + { type: 'photo', assetId: ID, layout: 'fullbleed', caption: html }, + { type: 'photo', assetId: ID, layout: 'contained', caption: html }, + { type: 'photo-pair', assetIds: [ID, ID2], caption: html }, + { type: 'photo-grid', assetIds: [ID, ID2, ID3], caption: html }, + ]; + for (const block of blocks) { + const once = serialize([block]); + const parsed = parseJournalMarkdown(once); + // The framing is exact: the caption comes back as the markdown that + // was written, rendered once, and the block keeps its type and ids. + expect(parsed.blocks).toHaveLength(1); + const [back] = parsed.blocks; + expect(back.type).toBe(block.type); + expect(back).toMatchObject( + block.type === 'photo' + ? { assetId: ID, layout: block.layout } + : { assetIds: 'assetIds' in block ? block.assetIds : [] }, + ); + const writtenCaption = once.slice(once.indexOf('](') + 2, -1); + expect('caption' in back ? back.caption : undefined).toBe( + renderInlineMarkdown(writtenCaption.trim()), + ); + // And a second save writes the same file. + expect(serialize(parsed.blocks)).toBe(once); + expectNoIdInText( + parsed.blocks.filter((b) => b.type === 'paragraph'), + [ID, ID2, ID3], + ); + } + }); + } + + it('parses any single-line caption exactly (seeded fuzz)', () => { + const alphabet = ['(', ')', '[', ']', '\\', '*', '_', ' ', 'a', 'Ü', '🏔️', '!', ':', ',']; + let seed = 0x5eed; + const next = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed; + }; + for (let n = 0; n < 500; n++) { + const length = next() % 24; + let caption = ''; + for (let i = 0; i < length; i++) caption += alphabet[next() % alphabet.length]; + const source = `![${ID}:wide](${caption})`; + const parsed = parseJournalMarkdown(source); + expect(parsed.blocks).toHaveLength(1); + expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID, layout: 'wide' }); + const expected = caption.trim() ? renderInlineMarkdown(caption.trim()) : undefined; + expect((parsed.blocks[0] as { caption?: string }).caption).toBe(expected); + // Idempotence of a save. Stray `*`/`_` runs are left out: the inline + // emphasis renderer normalises those on its own (`__*` → `***`), which is + // a property of renderInlineMarkdown, not of the photo-line framing. + if (!/[*_]/.test(caption)) { + const once = serializeJournalMarkdown(parsed); + expect(roundTrip(once)).toBe(once); + } + } + }); +}); + +describe('photo lines the old pattern accepted parse exactly as before', () => { + // The pattern before A-1, kept here as the reference for backward compatibility. + const legacy = (line: string) => { + const m = line.match(/^!\[([^\]]*)\]\(([^)]*)\)$/); + return m ? { target: m[1].trim(), caption: m[2].trim() } : null; + }; + + const lines = [ + `![${ID}]()`, + `![${ID}](Caption)`, + `![${ID}:wide](A **bold** caption)`, + `![${ID}:fullbleed](Offene Klammer (ohne Ende)`, + `![${ID}, ${ID2}](Anna's photos)`, + `![${ID}, ${ID2}, ${ID3}](Drei [eckige] Klammern)`, + `![](Placeholder)`, + `![:wide]()`, + `![${ID}](C:\\)`, + `![${ID}](a\\b \\( c)`, + `![${ID}](Emoji 🏔️ Umlaut ä)`, + `![${ID}]( Leerraum )`, + ]; + + for (const line of lines) { + it(JSON.stringify(line), () => { + const old = legacy(line); + expect(old).not.toBeNull(); + const blocks = parseJournalMarkdown(line).blocks; + expect(blocks).toHaveLength(1); + const expectedCaption = old!.caption ? renderInlineMarkdown(old!.caption) : undefined; + expect((blocks[0] as { caption?: string }).caption).toBe(expectedCaption); + const ids = + blocks[0].type === 'photo' + ? [blocks[0].assetId] + : 'assetIds' in blocks[0] + ? blocks[0].assetIds + : []; + expect(ids.join(', ')).toBe(old!.target.replace(/:(wide|fullbleed)$/, '')); + // Unchanged content saves to the same line. + expect(roundTrip(line)).toBe(roundTrip(roundTrip(line))); + }); + } +}); + +describe('a malformed photo line never prints asset ids (A-1)', () => { + it('splits two photo lines written without a blank line between them', () => { + const parsed = parseJournalMarkdown(`![${ID}](Erstes)\n![${ID2}:wide](Zweites (2))`); + expect(parsed.blocks).toEqual([ + { type: 'photo', assetId: ID, layout: 'contained', caption: 'Erstes' }, + { type: 'photo', assetId: ID2, layout: 'wide', caption: 'Zweites (2)' }, + ]); + }); + + it('splits a heading or sentence directly above a photo', () => { + const parsed = parseJournalMarkdown(`## Tag 1\n![${ID}](Gipfel)`); + expect(parsed.blocks).toEqual([ + { type: 'heading', level: 2, text: 'Tag 1' }, + { type: 'photo', assetId: ID, layout: 'contained', caption: 'Gipfel' }, + ]); + + const para = parseJournalMarkdown(`Wir stiegen auf.\n ![${ID}, ${ID2}](Oben)`); + expect(para.blocks.map((b) => b.type)).toEqual(['paragraph', 'photo-pair']); + expectNoIdInText(para.blocks, [ID, ID2]); + }); + + it('keeps text after the closing parenthesis in the caption, not a paragraph', () => { + const parsed = parseJournalMarkdown(`![${ID}](Gipfel) und weiter`); + expect(parsed.blocks).toHaveLength(1); + expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID }); + expectNoIdInText(parsed.blocks, [ID]); + // Stable from the first save on. + const once = roundTrip(`![${ID}](Gipfel) und weiter`); + expect(roundTrip(once)).toBe(once); + }); + + it('treats a reference without a caption group as a photo', () => { + for (const source of [`![${ID}]`, `![${ID}:wide] Text`, `![${ID}`]) { + const parsed = parseJournalMarkdown(source); + expect(parsed.blocks[0]).toMatchObject({ type: 'photo', assetId: ID }); + expectNoIdInText(parsed.blocks, [ID]); + } + }); + + it('leaves directive chunks alone', () => { + const parsed = parseJournalMarkdown(`::album abc\ncaption: ![x](y)`); + expect(parsed.blocks).toHaveLength(1); + expect(parsed.blocks[0]).toMatchObject({ type: 'album', albumId: 'abc' }); + }); +}); + +describe('reading time ignores photo lines', () => { + it('drops the whole line, parentheses in the caption included', () => { + const { words } = calculateReadingTime(`Eins zwei.\n\n![${ID}](Gipfel (560 m) oben)`); + expect(words).toBe(2); + }); +}); diff --git a/lib/__tests__/journal.test.ts b/lib/__tests__/journal.test.ts index c4782b9..1f9f585 100644 --- a/lib/__tests__/journal.test.ts +++ b/lib/__tests__/journal.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm } from 'fs/promises'; import { parseJournalMarkdown, serializeJournalMarkdown, @@ -7,22 +10,8 @@ import { isValidSlug, sanitizeSlug, } from '../journal'; -import { - writeJournalEntry, - readJournalEntry, - deleteJournalEntry, - listJournalEntries, -} from '../admin/journal-service'; describe('Journal Service & Parser', () => { - const testSlug = 'test-journey-nordkap'; - - afterEach(async () => { - try { - await deleteJournalEntry(testSlug); - } catch {} - }); - describe('slug validation and sanitization', () => { it('validates safe alphanumeric and hyphenated slugs', () => { expect(isValidSlug('my-story-2026')).toBe(true); @@ -149,7 +138,28 @@ A paragraph of text. }); describe('filesystem CRUD operations', () => { + // The service resolves content/journal from process.cwd() at import time. + // Without a temp cwd this wrote a real entry into the checkout's content/ + // — the live site's, in a deployment — and left a .deleted.bak behind. + const testSlug = 'test-journey-nordkap'; + let root: string; + let service: typeof import('../admin/journal-service'); + + beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-crud-')); + vi.spyOn(process, 'cwd').mockReturnValue(root); + vi.resetModules(); + service = await import('../admin/journal-service'); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); + }); + it('writes, reads, lists and deletes journal files safely', async () => { + const { writeJournalEntry, readJournalEntry, deleteJournalEntry, listJournalEntries } = + service; const testContent = `--- title: "Vitest Journey" date: "2026-08-14" diff --git a/lib/__tests__/journalDate.test.ts b/lib/__tests__/journalDate.test.ts new file mode 100644 index 0000000..838d0ec --- /dev/null +++ b/lib/__tests__/journalDate.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { formatJournalDate } from '../journalDate'; + +/** + * The /journal index formatted `new Date(frontmatter.date)` in the server's + * timezone: `2026-03-15` is UTC midnight, so every server west of Greenwich + * printed Mar 14. Hand-typed dates became "Invalid Date", and the entry page + * printed the raw string next to the index's localized one. + * + * Run under a negative UTC offset so a regression to local-time formatting + * shows up on any machine. + */ +let savedTz: string | undefined; +beforeAll(() => { + savedTz = process.env.TZ; + process.env.TZ = 'America/Los_Angeles'; +}); +afterAll(() => { + if (savedTz === undefined) delete process.env.TZ; + else process.env.TZ = savedTz; +}); + +describe('formatJournalDate', () => { + it('keeps the calendar day of a date-only value west of UTC', () => { + // Sanity check that the offset is in effect: this is the old code path. + expect(new Date('2026-03-15').getDate()).toBe(14); + expect(formatJournalDate('2026-03-15', 'en-US')).toBe('Mar 15, 2026'); + expect(formatJournalDate('2026-01-01', 'de-DE')).toBe('1. Jan. 2026'); + }); + + it('takes the written day of a value with a time', () => { + expect(formatJournalDate('2026-03-15T23:30:00+01:00', 'en-US')).toBe('Mar 15, 2026'); + expect(formatJournalDate('2026-03-15 08:00', 'en-US')).toBe('Mar 15, 2026'); + }); + + it('shows a year-month value without inventing a day', () => { + expect(formatJournalDate('2026-03', 'en-US')).toBe('March 2026'); + }); + + it.each(['15.03.2026', 'March 2026', 'Spring 2026', '2026-02-30', '2026-13', '2026-03-150'])( + 'shows %s as written instead of Invalid Date or a guessed day', + (raw) => { + expect(formatJournalDate(raw, 'en-US')).toBe(raw); + }, + ); + + it('returns null for a missing or blank date', () => { + expect(formatJournalDate(undefined, 'en-US')).toBeNull(); + expect(formatJournalDate(' ', 'en-US')).toBeNull(); + }); +}); diff --git a/lib/__tests__/mapService.test.ts b/lib/__tests__/mapService.test.ts index caa133a..aeee2f1 100644 --- a/lib/__tests__/mapService.test.ts +++ b/lib/__tests__/mapService.test.ts @@ -13,8 +13,15 @@ vi.mock('@/lib/immich', () => ({ }, })); +const config = vi.hoisted(() => ({ + value: { + standaloneAlbums: ['album-1'] as string[], + subpages: [] as Array<{ slug: string; albumIds: string[]; enabled: boolean; hidden?: boolean }>, + cacheTtl: 60_000, + }, +})); vi.mock('@/lib/config', () => ({ - getConfig: () => ({ subpages: [], cacheTtl: 60_000 }), + getConfig: () => config.value, })); vi.mock('@/lib/cache', () => ({ @@ -22,6 +29,7 @@ vi.mock('@/lib/cache', () => ({ })); import { getMapData } from '@/lib/mapService'; +import { listedAlbumIds } from '@/lib/config/schema'; const ALBUM = { id: 'album-1', albumName: 'Trip', slug: 'trip' }; @@ -30,6 +38,8 @@ function asset(id: string, exif: Record) { } beforeEach(() => { + config.value.standaloneAlbums = ['album-1']; + config.value.subpages = []; getAlbums.mockReset(); getAlbum.mockReset(); getAlbums.mockResolvedValue([ALBUM]); @@ -83,3 +93,101 @@ describe('getMapData coordinate filtering (#635)', () => { expect(locations).toHaveLength(0); }); }); + +/** + * `enabled: false` takes a subpage offline. Its albums stay on the allowlist + * (the configuration is kept), so the map has to leave them out itself: a + * marker names the album, links it, counts it and shows one of its photos. + */ +describe('getMapData and offline subpages', () => { + const berlin = { latitude: 52.5, longitude: 13.4, city: 'Berlin', country: 'Germany' }; + + beforeEach(() => { + getAlbum.mockResolvedValue({ id: ALBUM.id, assets: [asset('a', berlin)] }); + }); + + it('leaves out an album whose only subpage is offline', async () => { + config.value.standaloneAlbums = []; + config.value.subpages = [{ slug: 'old-series', albumIds: [ALBUM.id], enabled: false }]; + + expect(await getMapData()).toEqual([]); + }); + + it('keeps it when an enabled subpage lists it too, and links it there', async () => { + config.value.standaloneAlbums = []; + config.value.subpages = [ + { slug: 'old-series', albumIds: [ALBUM.id], enabled: false }, + { slug: 'travel', albumIds: [ALBUM.id], enabled: true }, + ]; + + const locations = await getMapData(); + + expect(locations).toHaveLength(1); + expect(locations[0].albums[0].subpageSlug).toBe('travel'); + }); +}); + +/** + * `hidden: true` keeps a subpage reachable by direct link only. The map is a + * listing: a marker names the album, links it under the subpage's address and + * shows one of its photos, so an album only a hidden subpage carries must not + * appear on it. Its photos stay reachable through the link (publishedAssets + * still asks onlineAlbumIds, which is not narrowed). + */ +describe('getMapData and hidden subpages', () => { + const berlin = { latitude: 52.5, longitude: 13.4, city: 'Berlin', country: 'Germany' }; + + beforeEach(() => { + getAlbum.mockResolvedValue({ id: ALBUM.id, assets: [asset('a', berlin)] }); + }); + + it('leaves out an album whose only subpage is hidden', async () => { + config.value.standaloneAlbums = []; + config.value.subpages = [ + { slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true }, + ]; + + expect(await getMapData()).toEqual([]); + expect(getAlbum).not.toHaveBeenCalled(); + }); + + it('keeps it when a listed subpage carries it too, and links it there', async () => { + config.value.standaloneAlbums = []; + config.value.subpages = [ + { slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true }, + { slug: 'travel', albumIds: [ALBUM.id], enabled: true }, + ]; + + const locations = await getMapData(); + + expect(locations).toHaveLength(1); + expect(locations[0].albums[0].subpageSlug).toBe('travel'); + }); + + it('keeps a standalone album that a hidden subpage also carries, linked standalone', async () => { + config.value.standaloneAlbums = [ALBUM.id]; + config.value.subpages = [ + { slug: 'private', albumIds: [ALBUM.id], enabled: true, hidden: true }, + ]; + + const locations = await getMapData(); + + expect(locations).toHaveLength(1); + expect(locations[0].albums[0].subpageSlug).toBeUndefined(); + }); +}); + +describe('listedAlbumIds', () => { + it('drops albums of offline and hidden subpages, keeps standalone and listed ones', () => { + const ids = listedAlbumIds({ + standaloneAlbums: ['s'], + subpages: [ + { albumIds: ['v', 'shared'], enabled: true }, + { albumIds: ['off'], enabled: false }, + { albumIds: ['h', 'shared'], enabled: true, hidden: true }, + ], + }); + + expect([...ids].sort()).toEqual(['s', 'shared', 'v']); + }); +}); diff --git a/lib/__tests__/pages-service.test.ts b/lib/__tests__/pages-service.test.ts new file mode 100644 index 0000000..89c4d20 --- /dev/null +++ b/lib/__tests__/pages-service.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'; + +/** + * content/pages/ storage (#722): the journal's rules — valid slugs only, a + * backup before every overwrite, ten saves kept, a `.deleted.bak` on delete. + */ +let root: string; +let pagesDir: string; +let backupDir: string; +let service: typeof import('../admin/pages-service'); + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'folio-pages-')); + pagesDir = path.join(root, 'content', 'pages'); + backupDir = path.join(pagesDir, '.backups'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.resetModules(); + service = await import('../admin/pages-service'); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); +}); + +const page = (title: string, extra = '') => + `---\ntitle: "${title}"\n${extra}---\n\nAbout ${title}\n`; + +describe('pages-service', () => { + it('writes, reads and lists a page', async () => { + await service.writePage('pricing', page('Pricing', 'draft: true\n')); + + const read = await service.readPage('pricing'); + expect(read?.parsed.frontmatter).toMatchObject({ title: 'Pricing', draft: true }); + expect(service.readPageSync('pricing')?.rawMarkdown).toBe(read?.rawMarkdown); + + const list = await service.listPages(); + expect(list).toEqual([ + { slug: 'pricing', frontmatter: expect.objectContaining({ title: 'Pricing' }) }, + ]); + expect(service.listPageSlugsSync()).toEqual(['pricing']); + }); + + it('keeps the SEO description in the frontmatter', async () => { + await service.writePage('faq', page('FAQ', 'description: "Questions, answered"\n')); + expect((await service.readPage('faq'))?.parsed.frontmatter.description).toBe( + 'Questions, answered', + ); + }); + + it('refuses a slug that is not one, and never touches a path outside', async () => { + await expect(service.writePage('../escape', 'x')).rejects.toThrow(/Invalid page slug/); + await expect(service.writePage('a/b', 'x')).rejects.toThrow(/Invalid page slug/); + expect(await service.readPage('../gallery')).toBeNull(); + expect(service.resolvePageFilePath('..')).toBeNull(); + }); + + it('answers null for a page that does not exist', async () => { + expect(await service.readPage('nothing')).toBeNull(); + expect(await service.listPages()).toEqual([]); + }); + + it('backs up the previous version before an overwrite, keeping ten', async () => { + await mkdir(pagesDir, { recursive: true }); + await writeFile(path.join(pagesDir, 'pricing.md'), page('v0')); + for (let i = 1; i <= 12; i++) { + await service.writePage('pricing', page(`v${i}`)); + // Distinct timestamps in the backup names. + await new Promise((r) => setTimeout(r, 2)); + } + const backups = await readdir(backupDir); + expect(backups).toHaveLength(10); + expect(await readFile(path.join(pagesDir, 'pricing.md'), 'utf8')).toBe(page('v12')); + }); + + it('keeps a .deleted.bak copy on delete, and restores from it', async () => { + await service.writePage('workshops', page('Workshops')); + await expect(service.deletePage('workshops')).resolves.toBe(true); + await expect(service.deletePage('workshops')).resolves.toBe(false); + expect(await service.readPage('workshops')).toBeNull(); + + const [backup] = await service.listPageBackups(); + expect(backup).toMatchObject({ slug: 'workshops', kind: 'deleted' }); + + await expect(service.restorePageBackup(backup.filename)).resolves.toBe('workshops'); + expect((await service.readPage('workshops'))?.parsed.frontmatter.title).toBe('Workshops'); + }); + + it('refuses to restore from a name it did not write', async () => { + await expect(service.restorePageBackup('../gallery.yaml')).rejects.toThrow(/unrecognised/); + await expect(service.restorePageBackup('pricing.md')).rejects.toThrow(/unrecognised/); + }); +}); diff --git a/lib/__tests__/pages.test.ts b/lib/__tests__/pages.test.ts new file mode 100644 index 0000000..29e9ae6 --- /dev/null +++ b/lib/__tests__/pages.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { + RESERVED_PAGE_SLUGS, + describeCollision, + menuPageSlugs, + pageSlugCollision, + resolveMenu, + type NavEntry, + type PageSummary, +} from '../pages'; +import { deriveGallery, type GalleryYaml } from '../config'; +import { rewritePageRefs } from '../admin/pageRefs'; + +const A1 = '11111111-1111-4111-8111-111111111111'; +const A2 = '22222222-2222-4222-8222-222222222222'; + +const none = { subpages: [], albums: [], journal: [] }; + +describe('pageSlugCollision (#722)', () => { + it('accepts a free slug', () => { + expect(pageSlugCollision('pricing', none)).toBeNull(); + }); + + it.each(RESERVED_PAGE_SLUGS)('refuses the built-in route /%s', (slug) => { + expect(pageSlugCollision(slug, none)).toEqual({ kind: 'reserved' }); + }); + + it('refuses a slug taken by a subpage, an album or a journal entry', () => { + const taken = { subpages: ['travel'], albums: ['iceland'], journal: ['trip'] }; + expect(pageSlugCollision('travel', taken)).toEqual({ kind: 'subpage' }); + expect(pageSlugCollision('iceland', taken)).toEqual({ kind: 'album' }); + expect(pageSlugCollision('trip', taken)).toEqual({ kind: 'journal' }); + }); + + it('compares without regard to case', () => { + expect(pageSlugCollision('Travel', { ...none, subpages: ['travel'] })).toEqual({ + kind: 'subpage', + }); + expect(pageSlugCollision('ADMIN', none)).toEqual({ kind: 'reserved' }); + }); + + it('refuses what is not a slug at all', () => { + expect(pageSlugCollision('a/b', none)).toEqual({ kind: 'invalid' }); + expect(pageSlugCollision('', none)).toEqual({ kind: 'invalid' }); + }); + + it('describes each collision in a sentence naming the slug', () => { + for (const kind of ['invalid', 'reserved', 'subpage', 'album', 'journal'] as const) { + expect(describeCollision('x', { kind })).toContain('x'); + } + }); +}); + +describe('deriveGallery: `- page:` references (#722)', () => { + const gallery = (subpages: NonNullable): GalleryYaml => ({ subpages }); + + it('keeps pages and subpages in one menu order', () => { + const { nav, subpages } = deriveGallery( + gallery([ + { page: 'pricing' }, + { name: 'Travel', albums: [A1] }, + { page: 'faq' }, + { name: 'Weddings', albums: [A2] }, + ]), + ); + expect(subpages.map((s) => s.slug)).toEqual(['travel', 'weddings']); + expect(nav).toEqual([ + { type: 'page', slug: 'pricing' }, + { type: 'subpage', slug: 'travel' }, + { type: 'page', slug: 'faq' }, + { type: 'subpage', slug: 'weddings' }, + ]); + expect(menuPageSlugs(nav)).toEqual(['pricing', 'faq']); + }); + + it('allows a gallery whose menu holds only pages', () => { + expect(deriveGallery(gallery([{ page: 'pricing' }])).nav).toEqual([ + { type: 'page', slug: 'pricing' }, + ]); + }); + + it('rejects an invalid page slug and a page listed twice', () => { + expect(() => deriveGallery(gallery([{ page: '../x' }]))).toThrow(/not a valid page slug/); + expect(() => deriveGallery(gallery([{ page: 'a' }, { page: 'a' }]))).toThrow(/more than once/); + }); + + it('builds the menu from the map form of subpages too', () => { + const { nav } = deriveGallery({ subpages: { Travel: [A1] } }); + expect(nav).toEqual([{ type: 'subpage', slug: 'travel' }]); + }); +}); + +describe('resolveMenu (#722)', () => { + const nav: NavEntry[] = [ + { type: 'subpage', slug: 'travel' }, + { type: 'page', slug: 'pricing' }, + { type: 'subpage', slug: 'hidden' }, + { type: 'page', slug: 'draft' }, + { type: 'page', slug: 'missing' }, + { type: 'page', slug: 'locked' }, + ]; + const pages: PageSummary[] = [ + { slug: 'pricing', frontmatter: { title: 'Pricing' } }, + { slug: 'draft', frontmatter: { title: 'Soon', draft: true } }, + { slug: 'locked', frontmatter: { password: 'scrypt:a:b' } }, + ]; + + it('interleaves visible subpages and published pages in gallery.yaml order', () => { + const menu = resolveMenu(nav, [{ slug: 'travel', name: 'Travel' }], pages); + expect(menu.map((m) => [m.href, m.label])).toEqual([ + ['/travel', 'Travel'], + ['/pricing', 'Pricing'], + // A protected page stays listed; its title falls back to the slug. + ['/locked', 'locked'], + ]); + }); + + // `name` is the gallery.yaml key the slug is built from ("south-korea"); + // `title` is what the page itself shows ("South Korea"). The menu showed + // the key. + it('labels a subpage with its title, falling back to its name', () => { + const menu = resolveMenu( + [ + { type: 'subpage', slug: 'south-korea' }, + { type: 'subpage', slug: 'travel' }, + ], + [ + { slug: 'south-korea', name: 'south-korea', title: 'South Korea' }, + { slug: 'travel', name: 'Travel' }, + ], + [], + ); + expect(menu.map((m) => m.label)).toEqual(['South Korea', 'Travel']); + }); +}); + +describe('rewritePageRefs (#722)', () => { + const list = [{ name: 'Travel' }, { page: 'pricing' }, { page: 'faq' }]; + + it('renames a reference in place', () => { + expect(rewritePageRefs(list, 'pricing', 'rates')).toEqual([ + { name: 'Travel' }, + { page: 'rates' }, + { page: 'faq' }, + ]); + }); + + it('removes a reference', () => { + expect(rewritePageRefs(list, 'faq', null)).toEqual([{ name: 'Travel' }, { page: 'pricing' }]); + }); + + it('returns the same list when nothing matches', () => { + expect(rewritePageRefs(list, 'nothing', null)).toBe(list); + }); +}); diff --git a/lib/__tests__/parsedFileCache.test.ts b/lib/__tests__/parsedFileCache.test.ts new file mode 100644 index 0000000..380e310 --- /dev/null +++ b/lib/__tests__/parsedFileCache.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, mkdir, rename, rm, unlink, writeFile } from 'fs/promises'; + +/** + * The journal and page listings run on every public render (the header nav + * lists both) and used to parse every markdown file each time. They now keep + * the parse per file while the file is unchanged — which must never cost an + * admin save its immediate visibility. + */ +let root: string; +let journalDir: string; +let pagesDir: string; +let parseSpy: ReturnType void>>; + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'folio-parsecache-')); + journalDir = path.join(root, 'content', 'journal'); + pagesDir = path.join(root, 'content', 'pages'); + await mkdir(journalDir, { recursive: true }); + await mkdir(pagesDir, { recursive: true }); + vi.spyOn(process, 'cwd').mockReturnValue(root); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.resetModules(); + parseSpy = vi.fn<(raw: string) => void>(); + vi.doMock('../journal', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + parseJournalMarkdown: (raw: string) => { + parseSpy(raw); + return actual.parseJournalMarkdown(raw); + }, + }; + }); +}); + +afterEach(async () => { + vi.doUnmock('../journal'); + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); +}); + +const doc = (title: string) => + `---\ntitle: "${title}"\ndate: "2026-01-01"\n---\n\nBody of ${title}\n`; + +describe('listJournalEntries', () => { + it('parses an unchanged file once across listings', async () => { + await writeFile(path.join(journalDir, 'a.md'), doc('Alpha')); + await writeFile(path.join(journalDir, 'b.md'), doc('Bravo')); + const service = await import('../admin/journal-service'); + + const first = await service.listJournalEntries(); + const second = await service.listJournalEntries(); + + expect(second).toEqual(first); + expect(second.map((e) => e.frontmatter.title).sort()).toEqual(['Alpha', 'Bravo']); + expect(parseSpy).toHaveBeenCalledTimes(2); + }); + + it('shows an admin save on the very next listing, even at the same size', async () => { + await writeFile(path.join(journalDir, 'a.md'), doc('Alpha')); + const service = await import('../admin/journal-service'); + expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Alpha'); + + // Same byte length, so only the inode (atomicWrite renames) tells them apart. + await service.writeJournalEntry('a', doc('Omega')); + + expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Omega'); + }); + + it('sees an edit made in place and a file renamed over the entry', async () => { + const file = path.join(journalDir, 'a.md'); + await writeFile(file, doc('Alpha')); + const service = await import('../admin/journal-service'); + await service.listJournalEntries(); + + await writeFile(file, doc('Alpha, revised')); + expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Alpha, revised'); + + await writeFile(path.join(root, 'tmp.md'), doc('Gamma')); + await rename(path.join(root, 'tmp.md'), file); + expect((await service.listJournalEntries())[0].frontmatter.title).toBe('Gamma'); + }); + + it('drops a deleted entry and hands out copies callers cannot poison', async () => { + await writeFile(path.join(journalDir, 'a.md'), doc('Alpha')); + await writeFile(path.join(journalDir, 'b.md'), doc('Bravo')); + const service = await import('../admin/journal-service'); + + const listed = await service.listJournalEntries(); + listed[0].frontmatter.title = 'mutated'; + listed[0].excerpt = 'mutated'; + expect((await service.listJournalEntries()).map((e) => e.frontmatter.title).sort()).toEqual([ + 'Alpha', + 'Bravo', + ]); + + await unlink(path.join(journalDir, 'b.md')); + expect((await service.listJournalEntries()).map((e) => e.slug)).toEqual(['a']); + }); +}); + +describe('listPages', () => { + it('parses an unchanged page once and sees a save at once', async () => { + await writeFile(path.join(pagesDir, 'pricing.md'), doc('Pricing')); + const service = await import('../admin/pages-service'); + + await service.listPages(); + const again = await service.listPages(); + expect(again).toEqual([ + { + slug: 'pricing', + frontmatter: { + title: 'Pricing', + description: undefined, + password: undefined, + draft: undefined, + }, + }, + ]); + expect(parseSpy).toHaveBeenCalledTimes(1); + + await service.writePage('pricing', doc('Tariffs')); + expect((await service.listPages())[0].frontmatter.title).toBe('Tariffs'); + }); +}); diff --git a/lib/__tests__/proofExport.test.ts b/lib/__tests__/proofExport.test.ts new file mode 100644 index 0000000..d0bdd74 --- /dev/null +++ b/lib/__tests__/proofExport.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { baseName, formatExport } from '../proofExport'; + +const picks = [ + { position: 3, fileName: 'IMG_0412.JPG', takenAt: '2026-05-01T10:00:00' }, + { position: 7, fileName: 'IMG_0412.CR3' }, + { position: 9, fileName: 'wedding "first", look.jpg' }, +]; + +describe('baseName', () => { + it('drops only the last extension', () => { + expect(baseName('IMG_0412.JPG')).toBe('IMG_0412'); + expect(baseName('a.b.tif')).toBe('a.b'); + expect(baseName('.hidden')).toBe('.hidden'); + expect(baseName('noext')).toBe('noext'); + }); +}); + +describe('formatExport', () => { + it('lists de-duplicated base names for a Lightroom filter', () => { + expect(formatExport(picks, 'lightroom')).toBe('IMG_0412, wedding "first", look'); + }); + + it('lists full names one per line', () => { + expect(formatExport(picks, 'txt')).toBe( + 'IMG_0412.JPG\nIMG_0412.CR3\nwedding "first", look.jpg\n', + ); + expect(formatExport([], 'txt')).toBe(''); + }); + + it('writes RFC 4180 CSV', () => { + expect(formatExport(picks, 'csv').split('\r\n')).toEqual([ + 'position,file_name,taken_at', + '3,IMG_0412.JPG,2026-05-01T10:00:00', + '7,IMG_0412.CR3,', + '9,"wedding ""first"", look.jpg",', + '', + ]); + }); + + it('never lets a file name become a spreadsheet formula', () => { + const csv = formatExport([{ position: 1, fileName: '=HYPERLINK("x")' }], 'csv'); + expect(csv).toContain(`"'=HYPERLINK(""x"")"`); + }); + + it('also neutralises names starting with a tab or carriage return', () => { + for (const name of ['\t=1+1', '\r=1+1']) { + const row = formatExport([{ position: 1, fileName: name }], 'csv').split('\r\n')[1]; + expect(row).toMatch(/^1,"?'/); + } + }); +}); diff --git a/lib/__tests__/proofSessions.test.ts b/lib/__tests__/proofSessions.test.ts new file mode 100644 index 0000000..3fb560e --- /dev/null +++ b/lib/__tests__/proofSessions.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + claimDownload, + createSession, + deleteSession, + downloadsRemaining, + findSessionByToken, + getSessionById, + isExpired, + isTokenShaped, + listSessions, + newToken, + ProofError, + saveSelection, + sessionInputSchema, + sessionPatchSchema, + sessionState, + submitSelection, + updateSession, + type ProofSession, +} from '../proofSessions'; + +const ALBUM = '11111111-1111-1111-1111-111111111111'; +let dir: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folio-proof-')); + vi.spyOn(process, 'cwd').mockReturnValue(dir); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +const storePath = () => path.join(dir, 'content', 'proofing.json'); + +function input(overrides: Record = {}) { + return sessionInputSchema.parse({ clientName: 'Anna', albumId: ALBUM, ...overrides }); +} + +describe('tokens', () => { + it('are 192-bit URL-safe strings and never repeat', () => { + const a = newToken(); + expect(isTokenShaped(a)).toBe(true); + expect(a).not.toBe(newToken()); + }); + + it('rejects anything else before touching the store', async () => { + expect(isTokenShaped('../../etc/passwd')).toBe(false); + expect(isTokenShaped('short')).toBe(false); + expect(await findSessionByToken('short')).toBeNull(); + }); +}); + +describe('expiry', () => { + it('is inclusive of the last day', () => { + const session = { expiresOn: '2026-05-12' }; + expect(isExpired(session, new Date('2026-05-12T22:00:00'))).toBe(false); + expect(isExpired(session, new Date('2026-05-13T00:00:01'))).toBe(true); + }); + + it('never expires without a date', () => { + expect(isExpired({}, new Date('2999-01-01'))).toBe(false); + }); +}); + +describe('input validation', () => { + it('requires a client name and an album UUID', () => { + expect(sessionInputSchema.safeParse({ clientName: ' ', albumId: ALBUM }).success).toBe(false); + expect(sessionInputSchema.safeParse({ clientName: 'A', albumId: 'nope' }).success).toBe(false); + }); + + it('rejects malformed dates and limits', () => { + expect(sessionInputSchema.safeParse({ ...input(), expiresOn: '12.05.2026' }).success).toBe( + false, + ); + expect(sessionInputSchema.safeParse({ ...input(), downloadLimit: 0 }).success).toBe(false); + }); + + it('does not let a patch move a link to another album', () => { + const parsed = sessionPatchSchema.parse({ albumId: ALBUM, clientName: 'B' }); + expect(parsed).not.toHaveProperty('albumId'); + }); +}); + +describe('store', () => { + it('creates, finds and lists a session, written 0600', async () => { + const session = await createSession(input({ download: 'selection', downloadLimit: 2 })); + expect(session.selection).toEqual([]); + expect(session.downloadsUsed).toBe(0); + expect(await findSessionByToken(session.token)).toMatchObject({ id: session.id }); + expect(await getSessionById(session.id)).toMatchObject({ clientName: 'Anna' }); + expect(await listSessions()).toHaveLength(1); + if (process.platform !== 'win32') { + expect(fs.statSync(storePath()).mode & 0o777).toBe(0o600); + } + }); + + it('saves and de-duplicates a selection', async () => { + const { token } = await createSession(input()); + const saved = await saveSelection(token, ['a', 'b', 'a']); + expect(saved.selection).toEqual(['a', 'b']); + expect(saved.updatedAt).toBeTruthy(); + }); + + it('serialises concurrent saves instead of losing one', async () => { + const one = await createSession(input({ clientName: 'One' })); + const two = await createSession(input({ clientName: 'Two' })); + await Promise.all([saveSelection(one.token, ['x']), saveSelection(two.token, ['y'])]); + expect((await getSessionById(one.id))?.selection).toEqual(['x']); + expect((await getSessionById(two.id))?.selection).toEqual(['y']); + }); + + it('locks the selection on submit, and only the first submit counts', async () => { + const { token, id } = await createSession(input()); + await saveSelection(token, ['a']); + const first = await submitSelection(token); + const second = await submitSelection(token); + expect(first.firstSubmit).toBe(true); + expect(second.firstSubmit).toBe(false); + await expect(saveSelection(token, ['b'])).rejects.toMatchObject({ code: 'submitted' }); + expect(sessionState((await getSessionById(id)) as ProofSession)).toBe('submitted'); + + // The photographer can reopen it. + await updateSession(id, { reopen: true }); + expect((await saveSelection(token, ['b'])).selection).toEqual(['b']); + }); + + it('refuses every client action on an expired link', async () => { + const { token } = await createSession(input({ expiresOn: '2000-01-01' })); + await expect(saveSelection(token, ['a'])).rejects.toMatchObject({ code: 'expired' }); + await expect(submitSelection(token)).rejects.toMatchObject({ code: 'expired' }); + }); + + it('counts downloads against the limit', async () => { + const created = await createSession(input({ download: 'album', downloadLimit: 2 })); + await claimDownload(created.token); + const after = await claimDownload(created.token); + expect(downloadsRemaining(after)).toBe(0); + await expect(claimDownload(created.token)).rejects.toMatchObject({ code: 'limit' }); + + await updateSession(created.id, { resetDownloads: true }); + expect(downloadsRemaining(await claimDownload(created.token))).toBe(1); + }); + + it('refuses to submit an empty selection, decided under the write lock', async () => { + const { token, id } = await createSession(input()); + await saveSelection(token, ['a']); + // A save of [] landing between the route's snapshot and the submit. + await saveSelection(token, []); + await expect(submitSelection(token)).rejects.toMatchObject({ code: 'empty' }); + expect((await getSessionById(id))?.submittedAt).toBeUndefined(); + }); + + it('re-checks the download scope under the lock', async () => { + const { token, id } = await createSession(input({ download: 'album' })); + // The photographer narrows the link while a request is on its way. + await updateSession(id, { download: 'selection' }); + await expect(claimDownload(token, 'album')).rejects.toMatchObject({ code: 'not-found' }); + await expect(claimDownload(token, 'selection')).resolves.toBeTruthy(); + }); + + it('refuses downloads on a link that offers none', async () => { + const { token } = await createSession(input()); + await expect(claimDownload(token)).rejects.toBeInstanceOf(ProofError); + }); + + it('clears optional fields with null in a patch', async () => { + const { id } = await createSession( + input({ expiresOn: '2030-01-01', download: 'album', downloadLimit: 3 }), + ); + const patched = await updateSession(id, { expiresOn: null, downloadLimit: null }); + expect(patched.expiresOn).toBeUndefined(); + expect(patched.downloadLimit).toBeUndefined(); + expect(downloadsRemaining(patched)).toBeNull(); + }); + + it('deletes a link, which stops it working', async () => { + const { id, token } = await createSession(input()); + await deleteSession(id); + expect(await findSessionByToken(token)).toBeNull(); + await expect(deleteSession(id)).rejects.toMatchObject({ code: 'not-found' }); + }); + + it('does not overwrite a store it cannot parse', async () => { + fs.mkdirSync(path.dirname(storePath()), { recursive: true }); + fs.writeFileSync(storePath(), '{ not json'); + await expect(createSession(input())).rejects.toThrow(); + expect(fs.readFileSync(storePath(), 'utf8')).toBe('{ not json'); + // …and the queue is not stuck behind the failure. + fs.writeFileSync(storePath(), '{"sessions":[]}'); + await expect(createSession(input())).resolves.toBeTruthy(); + }); +}); diff --git a/lib/__tests__/proofWebhook.test.ts b/lib/__tests__/proofWebhook.test.ts new file mode 100644 index 0000000..5333896 --- /dev/null +++ b/lib/__tests__/proofWebhook.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { buildWebhookPayload, notifySubmitted, webhookUrl } from '../proofWebhook'; +import type { ProofSession } from '../proofSessions'; + +const session: ProofSession = { + id: 'abc123', + token: 'x'.repeat(32), + clientName: 'Anna & Ben', + albumId: '11111111-1111-1111-1111-111111111111', + createdAt: '2026-01-01T00:00:00Z', + download: 'none', + downloadsUsed: 0, + selection: ['a', 'b'], + submittedAt: '2026-02-01T00:00:00Z', +}; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('webhookUrl', () => { + it('accepts http(s) only', () => { + expect(webhookUrl('https://ntfy.example/x')).toBe('https://ntfy.example/x'); + expect(webhookUrl('ftp://x')).toBeNull(); + expect(webhookUrl('not a url')).toBeNull(); + expect(webhookUrl('')).toBeNull(); + }); +}); + +describe('buildWebhookPayload', () => { + it('speaks Discord, Slack and Gotify at once, and never carries the client token', () => { + const payload = buildWebhookPayload(session, 'Wedding', 'https://folio.example'); + expect(payload.content).toContain('Anna & Ben'); + expect(payload.text).toContain('2 photos'); + expect(payload.title).toBeTruthy(); + expect(payload.message).toContain('"Wedding"'); + expect(payload.proofing.adminUrl).toBe('https://folio.example/admin/proofing?session=abc123'); + expect(JSON.stringify(payload)).not.toContain(session.token); + }); + + it('works without a site URL', () => { + expect(buildWebhookPayload(session, 'Wedding', null).proofing.adminUrl).toBeNull(); + }); +}); + +describe('notifySubmitted', () => { + it('does nothing without a URL', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + await notifySubmitted(session, 'Wedding', null); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('posts JSON to the configured URL', async () => { + vi.stubEnv('PROOFING_WEBHOOK_URL', 'https://hooks.example/abc'); + const fetchMock = vi.fn(async () => new Response('ok')); + vi.stubGlobal('fetch', fetchMock); + await notifySubmitted(session, 'Wedding', null); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://hooks.example/abc'); + expect(JSON.parse(init.body as string).event).toBe('proofing.submitted'); + }); + + it('swallows a failing webhook', async () => { + vi.stubEnv('PROOFING_WEBHOOK_URL', 'https://hooks.example/abc'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Promise.reject(new Error('down'))), + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(notifySubmitted(session, 'Wedding', null)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/proofing-resolution.test.ts b/lib/__tests__/proofing-resolution.test.ts index 8132b93..eaaaa92 100644 --- a/lib/__tests__/proofing-resolution.test.ts +++ b/lib/__tests__/proofing-resolution.test.ts @@ -14,7 +14,7 @@ vi.mock('@/lib/env', () => ({ }, })); -import { resolveProofing } from '@/lib/config'; +import { resolveProofing, resolveProofingEmail } from '@/lib/config'; /** * Client proofing hands a gallery to a client and takes their picks back, so @@ -48,3 +48,33 @@ describe('resolveProofing', () => { expect(resolveProofing({ proofing: false }, true)).not.toBe(resolveProofing(undefined, true)); }); }); + +/** + * The proofing dialog's "email to photographer" needs a recipient (#736): the + * footer contact email by default, overridable with `proofing.email`. Neither + * set means no recipient, and the dialog hides the button rather than opening + * a compose with an empty To:. + */ +describe('resolveProofingEmail', () => { + it('prefers the proofing address over the footer contact email', () => { + expect(resolveProofingEmail('proofs@example.com', 'hello@example.com')).toBe( + 'proofs@example.com', + ); + }); + + it('falls back to the footer contact email', () => { + expect(resolveProofingEmail(undefined, 'hello@example.com')).toBe('hello@example.com'); + expect(resolveProofingEmail('', 'hello@example.com')).toBe('hello@example.com'); + expect(resolveProofingEmail(' ', 'hello@example.com')).toBe('hello@example.com'); + }); + + it('is undefined when neither is set, so the button is hidden', () => { + expect(resolveProofingEmail(undefined, undefined)).toBeUndefined(); + expect(resolveProofingEmail('', '')).toBeUndefined(); + expect(resolveProofingEmail(' ', ' ')).toBeUndefined(); + }); + + it('trims surrounding whitespace', () => { + expect(resolveProofingEmail(' proofs@example.com ', undefined)).toBe('proofs@example.com'); + }); +}); diff --git a/lib/__tests__/proxy.test.ts b/lib/__tests__/proxy.test.ts index d0d7a7b..f91cbb2 100644 --- a/lib/__tests__/proxy.test.ts +++ b/lib/__tests__/proxy.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/config', () => ({ import { isSiteUnlocked } from '@/lib/auth'; import { cdnOrigin } from '@/lib/cdn'; import { tryToParsePath } from 'next/dist/lib/try-to-parse-path'; -import { proxy, config, isKnownMissing } from '@/proxy'; +import { proxy, config, isKnownMissing, TOP_LEVEL_ROUTES } from '@/proxy'; import { getConfigOrNull } from '@/lib/config'; import fs from 'node:fs'; import os from 'node:os'; @@ -307,6 +307,11 @@ describe('isKnownMissing', () => { expect(isKnownMissing('/journal/..%2Fsettings', contentDir)).toBe(true); }); + it('treats a malformed escape in a journal slug as missing instead of throwing', () => { + expect(isKnownMissing('/journal/%E0', contentDir)).toBe(true); + expect(proxy(new NextRequest('https://example.com/journal/%E0')).status).toBe(404); + }); + it('leaves album and subpage slugs to the page, which needs Immich to decide', () => { expect(isKnownMissing('/japan', contentDir)).toBe(false); expect(isKnownMissing('/japan/tokyo', contentDir)).toBe(false); @@ -317,6 +322,60 @@ describe('isKnownMissing', () => { expect(isKnownMissing('/contact', contentDir)).toBe(false); }); + it('knows paths deeper than the catch-all serves', () => { + expect(isKnownMissing('/x/y/z', contentDir)).toBe(true); + expect(isKnownMissing('/japan/tokyo/extra/', contentDir)).toBe(true); + // Deep paths below a fixed route are that route's to answer. + expect(isKnownMissing('/admin/settings/general', contentDir)).toBe(false); + expect(isKnownMissing('/admin/journal/kyoto', contentDir)).toBe(false); + expect(isKnownMissing('/proof/abc/def', contentDir)).toBe(false); + // Next's internals and dotfiles are not the catch-all's either. + expect(isKnownMissing('/_next/data/build/x.json', contentDir)).toBe(false); + expect(isKnownMissing('/.well-known/appspecific/x.json', contentDir)).toBe(false); + }); + + it('lists every top-level route directory under app/', () => { + // A route directory missing from the list would have its deep paths + // answered 404 while the page renders normally. + const appDir = path.join(process.cwd(), 'app'); + const hasRoute = (dir: string): boolean => + fs + .readdirSync(dir, { withFileTypes: true }) + .some((entry) => + entry.isDirectory() + ? hasRoute(path.join(dir, entry.name)) + : /^(page|route)\.(tsx?|jsx?)$/.test(entry.name), + ); + const routeDirs = fs + .readdirSync(appDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== '[...path]') + .filter((entry) => hasRoute(path.join(appDir, entry.name))) + .map((entry) => entry.name); + expect(routeDirs.length).toBeGreaterThan(5); + for (const name of routeDirs) expect(TOP_LEVEL_ROUTES.has(name), name).toBe(true); + }); + + /* + * #704 answered with NextResponse.rewrite(request.nextUrl, { status: 404 }). + * NextURL reports 127.0.0.1 as "localhost", so behind `next start -H + * 127.0.0.1` the destination's origin no longer matched the server's and + * Next proxied it as an external URL to localhost — ::1, where nothing + * listened: a 30 s hang and a 500 for every known-missing path. + */ + it.each(['/journal/nope', '/contact', '/x/y/z'])( + 'answers %s with a 404 on the same request, not a rewrite to another origin', + (pathname) => { + const res = proxy(new NextRequest(`http://127.0.0.1:3591${pathname}`)); + expect(res.status).toBe(404); + expect(res.headers.get('x-middleware-rewrite')).toBeNull(); + expect(res.headers.get('x-middleware-next')).toBe('1'); + // The page still gets its nonce, and the policy still matches it. + const nonce = res.headers.get('x-middleware-request-x-nonce'); + expect(nonce).toBeTruthy(); + expect(res.headers.get('Content-Security-Policy')).toContain(`'nonce-${nonce}'`); + }, + ); + it('makes proxy() answer 404 with the policy still set', () => { const res = proxy(new NextRequest('https://example.com/contact')); expect(res.status).toBe(404); diff --git a/lib/__tests__/publicPages.test.ts b/lib/__tests__/publicPages.test.ts index d69b17a..1e8e18b 100644 --- a/lib/__tests__/publicPages.test.ts +++ b/lib/__tests__/publicPages.test.ts @@ -123,8 +123,21 @@ describe('publicPaths', () => { site({ journalEnabled: true, journal: [ - { slug: 'published', draft: false }, - { slug: 'wip', draft: true }, + { slug: 'published', draft: false, isProtected: false }, + { slug: 'wip', draft: true, isProtected: false }, + ], + }), + ); + expect(paths).toEqual(['/', '/journal', '/journal/published']); + }); + + it('omits a password-protected journal entry, as the journal index does', () => { + const paths = publicPaths( + site({ + journalEnabled: true, + journal: [ + { slug: 'published', draft: false, isProtected: false }, + { slug: 'private', draft: false, isProtected: true }, ], }), ); @@ -133,7 +146,10 @@ describe('publicPaths', () => { it('omits journal entries entirely when the journal is off', () => { const paths = publicPaths( - site({ journalEnabled: false, journal: [{ slug: 'published', draft: false }] }), + site({ + journalEnabled: false, + journal: [{ slug: 'published', draft: false, isProtected: false }], + }), ); expect(paths).toEqual(['/']); }); @@ -150,12 +166,38 @@ describe('publicPaths', () => { expect(paths).toEqual([...new Set(paths)]); }); + it('lists a published content page, whether or not it is in the menu (#722)', () => { + const paths = publicPaths( + site({ pages: [{ slug: 'pricing', draft: false, isProtected: false }] }), + ); + expect(paths).toEqual(['/', '/pricing']); + }); + + it('leaves out a draft content page and a password-protected one', () => { + const paths = publicPaths( + site({ + pages: [ + { slug: 'draft', draft: true, isProtected: false }, + { slug: 'secret', draft: false, isProtected: true }, + ], + }), + ); + expect(paths).toEqual(['/']); + }); + + it('lists no content page on a locked site', () => { + const paths = publicPaths( + site({ siteLocked: true, pages: [{ slug: 'pricing', draft: false, isProtected: false }] }), + ); + expect(paths).toEqual([]); + }); + it('emits only absolute-looking, single-slash paths', () => { const paths = publicPaths( site({ aboutEnabled: true, journalEnabled: true, - journal: [{ slug: 'story', draft: false }], + journal: [{ slug: 'story', draft: false, isProtected: false }], subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])], standaloneAlbums: [{ id: 'b', slug: 'portraits' }], }), diff --git a/lib/__tests__/publishedAssets.test.ts b/lib/__tests__/publishedAssets.test.ts new file mode 100644 index 0000000..3d0e8c7 --- /dev/null +++ b/lib/__tests__/publishedAssets.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * GHSA-gfh4-6275-9gqv: the media routes serve only assets the site shows. Each + * source that puts an asset on a public page must land in the set, or that + * page loses its photos; nothing else may. + */ + +const state = vi.hoisted(() => ({ + config: { + albums: ['album-pub', 'album-offline'], + standaloneAlbums: ['album-pub'], + heroImages: ['hero-1'], + albumHeroImages: { + 'album-pub': 'album-hero', + 'album-offline': 'offline-hero', + } as Record, + subpages: [ + { albumIds: [], enabled: true, essayText: '![album:album-essay](x)' }, + // Taken offline with `enabled: false`: its albums stay on the allowlist. + { + albumIds: ['album-offline'], + enabled: false, + essayText: '![album:album-offline-essay](x)', + }, + ] as Array<{ albumIds: string[]; enabled: boolean; essayText?: string }>, + cacheTtl: 300, + }, + albums: { + 'album-pub': { albumThumbnailAssetId: 'thumb', assets: [{ id: 'a1' }, { id: 'a2' }] }, + 'album-offline': { albumThumbnailAssetId: 'offline-thumb', assets: [{ id: 'offline-1' }] }, + } as Record, + raw: { + 'album-journal': [{ id: 'j-album-1' }], + 'album-essay': [{ id: 'essay-1' }], + 'album-offline-essay': [{ id: 'offline-essay-1' }], + 'album-page': [{ id: 'page-album-1' }], + } as Record, + proofAlbums: { + 'album-proof': { albumThumbnailAssetId: null, assets: [{ id: 'proof-1' }] }, + 'album-expired': { albumThumbnailAssetId: null, assets: [{ id: 'expired-1' }] }, + } as Record, + builds: 0, + mtime: 1, + /** While set, a proofing album lookup reads its answer, then waits for it. */ + gate: null as Promise | null, + onGate: null as (() => void) | null, +})); + +vi.mock('../config', () => ({ getConfig: () => state.config })); + +vi.mock('../immich', () => ({ + immich: { + getAlbum: async (id: string) => { + state.builds += id === 'album-pub' ? 1 : 0; + return state.albums[id] ?? null; + }, + getProofingAlbum: async (id: string) => { + const album = state.proofAlbums[id]; + const answer = album ? { ...album, assets: [...album.assets] } : null; + if (state.gate) { + state.onGate?.(); + await state.gate; + } + return answer; + }, + getAlbumAssetsRaw: async (id: string) => state.raw[id] ?? [], + }, +})); + +vi.mock('../admin/journal-service', () => ({ + listJournalEntries: async () => [{ slug: 'trip' }, { slug: 'draft' }], + readJournalEntry: async (slug: string) => ({ + slug, + rawMarkdown: '', + parsed: + slug === 'trip' + ? { + frontmatter: { coverAssetId: 'j-cover' }, + blocks: [ + { type: 'photo', assetId: 'j-photo', layout: 'wide' }, + { type: 'album', albumId: 'album-journal', layout: 'grid' }, + ], + } + : { frontmatter: {}, blocks: [{ type: 'photo', assetId: 'draft-photo', layout: 'wide' }] }, + }), +})); + +vi.mock('../admin/pages-service', () => ({ + listPages: async () => [{ slug: 'pricing', frontmatter: {} }], + readPage: async (slug: string) => ({ + slug, + rawMarkdown: '', + parsed: { + frontmatter: { draft: true }, + blocks: [ + { type: 'photo', assetId: 'page-photo', layout: 'wide' }, + { type: 'album', albumId: 'album-page', layout: 'grid' }, + { + type: 'map', + line: true, + items: [{ kind: 'photo', assetId: 'page-map-photo' }], + }, + ], + }, + }), +})); + +vi.mock('../journal', async (orig) => { + const actual = await orig(); + return { + ...actual, + parseJournalMarkdown: (md: string) => { + const albumId = /album:([\w-]+)/.exec(md)?.[1]; + return albumId + ? { frontmatter: {}, blocks: [{ type: 'album', albumId, layout: 'grid' }] } + : actual.parseJournalMarkdown(md); + }, + }; +}); + +vi.mock('../proofSessions', () => ({ + listSessions: async () => [ + { albumId: 'album-proof', expiresOn: undefined }, + { albumId: 'album-expired', expiresOn: '2000-01-01' }, + ], + isExpired: (s: { expiresOn?: string }) => s.expiresOn === '2000-01-01', +})); + +vi.mock('fs/promises', () => ({ + default: { + readFile: async () => '---\nportrait: portrait-1\n---\nBio', + stat: async () => ({ mtimeMs: state.mtime }), + }, +})); + +import { isPublishedAsset, resetPublishedAssetsForTest } from '../publishedAssets'; + +beforeEach(() => { + resetPublishedAssetsForTest(); + state.builds = 0; +}); + +describe('isPublishedAsset', () => { + it.each([ + ['an album photo', 'a1'], + ['an album thumbnail', 'thumb'], + ['a homepage hero', 'hero-1'], + ['an album hero', 'album-hero'], + ['the about portrait', 'portrait-1'], + ['a journal cover', 'j-cover'], + ['a journal photo block', 'j-photo'], + ["a journal album block's photo", 'j-album-1'], + ['a draft entry photo (its page gate hides it)', 'draft-photo'], + ["an inline essay's album block photo", 'essay-1'], + ['a content page photo block (draft included)', 'page-photo'], + ["a content page album block's photo", 'page-album-1'], + ['a photo of an open proofing link', 'proof-1'], + ])('serves %s', async (_label, id) => { + expect(await isPublishedAsset(id)).toBe(true); + }); + + it('refuses a photo of an expired proofing link', async () => { + expect(await isPublishedAsset('expired-1')).toBe(false); + }); + + it('refuses a photo only a content page map block names (maps do not render on pages)', async () => { + expect(await isPublishedAsset('page-map-photo')).toBe(false); + }); + + it.each([ + ['a photo of its album', 'offline-1'], + ['its album thumbnail', 'offline-thumb'], + ['its album hero', 'offline-hero'], + ["an album block's photo in its inline essay", 'offline-essay-1'], + ])('refuses %s once a subpage is offline', async (_label, id) => { + expect(await isPublishedAsset(id)).toBe(false); + }); + + it('refuses any other asset in the library', async () => { + expect(await isPublishedAsset('never-published')).toBe(false); + }); + + it('builds once and rebuilds on a miss at most every ten seconds', async () => { + await isPublishedAsset('a1'); + await isPublishedAsset('a2'); + expect(state.builds).toBe(1); + + await isPublishedAsset('unknown-1'); // forced rebuild + await isPublishedAsset('unknown-2'); // throttled + expect(state.builds).toBe(2); + }); + + it('rebuilds at once when a content file changes, e.g. a revoked link', async () => { + expect(await isPublishedAsset('proof-1')).toBe(true); + delete state.proofAlbums['album-proof'].assets[0]; + state.proofAlbums['album-proof'].assets = []; + state.mtime += 1; // proofing.json rewritten by the admin + try { + expect(await isPublishedAsset('proof-1')).toBe(false); + } finally { + state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }]; + } + }); + + /** + * The files used to be checked at most every 2 s. A request in that window + * after a save was answered from the set before it — with `immutable`, so + * the browser kept the photo. + */ + it('sees a save made milliseconds after the previous check', async () => { + expect(await isPublishedAsset('proof-1')).toBe(true); + expect(await isPublishedAsset('a1')).toBe(true); // a check a moment ago + state.proofAlbums['album-proof'].assets = []; + state.mtime += 1; + try { + expect(await isPublishedAsset('proof-1')).toBe(false); + } finally { + state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }]; + } + }); + + /** + * A request that finds a build already running joins it. When that build + * read the content before the save, its set must not answer for a request + * that saw the save. + */ + it('does not answer from a build that read the content before the save', async () => { + expect(await isPublishedAsset('proof-1')).toBe(true); + + let release!: () => void; + state.gate = new Promise((r) => (release = r)); + const reached = new Promise((r) => (state.onGate = r)); + state.mtime += 1; // an unrelated save starts a rebuild… + const first = isPublishedAsset('a1'); + await reached; // …which has read the proofing album and is waiting on Immich + + state.proofAlbums['album-proof'].assets = []; + state.mtime += 1; // the link is revoked + const second = isPublishedAsset('proof-1'); + + state.gate = null; + state.onGate = null; + release(); + try { + expect(await first).toBe(true); + expect(await second).toBe(false); + } finally { + state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }]; + } + }); + + /** + * The first view of a newly published album: a burst of image requests for + * photos the set has never seen. Only the first one rebuilt; the rest hit + * the rebuild throttle and were refused while that rebuild was still + * running — 11 of 13 photos broken until a reload. + */ + describe('a burst of requests for a newly published album', () => { + const NEW = Array.from({ length: 13 }, (_, i) => ({ id: `fresh-${i}` })); + + beforeEach(async () => { + await isPublishedAsset('a1'); // built, and checked a moment ago + }); + + afterEach(() => { + state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }]; + }); + + it('serves every photo when the save changed a content file', async () => { + state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW]; + state.mtime += 1; // gallery.yaml saved by the admin + const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id))); + expect(answers).toEqual(NEW.map(() => true)); + }); + + it('serves every photo when only Immich changed (misses share one rebuild)', async () => { + state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW]; + const before = state.builds; + const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id))); + expect(answers).toEqual(NEW.map(() => true)); + expect(state.builds - before).toBe(1); + }); + + it('still refuses what the rebuild did not publish', async () => { + const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id))); + expect(answers).toEqual(NEW.map(() => false)); + }); + }); + + it('picks up an asset published since the last build', async () => { + await isPublishedAsset('a1'); + state.albums['album-pub'].assets.push({ id: 'new-photo' }); + try { + expect(await isPublishedAsset('new-photo')).toBe(true); + } finally { + state.albums['album-pub'].assets.pop(); + } + }); +}); diff --git a/lib/__tests__/refusal-palette.test.ts b/lib/__tests__/refusal-palette.test.ts new file mode 100644 index 0000000..dec1941 --- /dev/null +++ b/lib/__tests__/refusal-palette.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { THEME_PRESETS } from '../config/theme'; + +/** + * The download refusal page cannot load the site's stylesheets, so it carries + * each preset's surface and text colours itself. This keeps that copy honest: + * it resolves the same custom properties from app/tokens.css the way the + * cascade does, and fails when a preset changes a colour the copy still has. + */ + +vi.mock('../immich', () => ({ immich: {} })); + +import { REFUSAL_PALETTES } from '../zipArchive'; + +const css = fs + .readFileSync(path.join(__dirname, '../../app/tokens.css'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, ''); + +/** Custom properties declared by the rule with exactly this selector. */ +function block(selector: string): Record { + const out: Record = {}; + for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) { + if (m[1].trim() !== selector) continue; + for (const d of m[2].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) out[d[1]] = d[2].trim(); + } + return out; +} + +/** What `` resolves, in cascade order. */ +function resolve(preset: string, mode: 'dark' | 'light') { + const light = mode === 'light'; + const vars = { + ...block(':root'), + ...(light ? block("[data-theme='light']") : {}), + ...block(`[data-preset='${preset}']`), + ...(light ? block(`[data-preset='${preset}'][data-theme='light']`) : {}), + }; + return { + bg: vars['--bg-primary'], + text: vars['--text-primary'], + muted: vars['--text-secondary'], + }; +} + +describe('refusal page palette', () => { + const cases = Object.keys(THEME_PRESETS).flatMap((preset) => + (['dark', 'light'] as const).map((mode) => [preset, mode] as const), + ); + + it.each(cases)('%s in %s mode matches app/tokens.css', (preset, mode) => { + const palette = REFUSAL_PALETTES[preset] ?? REFUSAL_PALETTES.default; + expect(palette[mode]).toEqual(resolve(preset, mode)); + }); + + it('no theme stylesheet redefines the colours the copy is taken from', () => { + const dir = path.join(__dirname, '../../app/themes'); + for (const file of fs.readdirSync(dir)) { + const text = fs.readFileSync(path.join(dir, file), 'utf8'); + expect(text, file).not.toMatch(/--(bg-primary|text-primary|text-secondary)\s*:/); + } + }); +}); diff --git a/lib/__tests__/settingValues.test.ts b/lib/__tests__/settingValues.test.ts new file mode 100644 index 0000000..931dbba --- /dev/null +++ b/lib/__tests__/settingValues.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { validateSettingValues } from '../config/settingValues'; +import { resolveTheme } from '../config/theme'; +import { normaliseSiteUrl } from '../siteUrl'; + +const fields = (input: unknown) => validateSettingValues(input).map((e) => e.field); + +/** + * The save route and the doctor refuse exactly what the site would ignore, no + * more (QA A-14). Each case is paired with the resolver it mirrors, so the two + * cannot drift apart unnoticed. + */ +describe('validateSettingValues', () => { + it('passes an empty or partial file', () => { + expect(fields({})).toEqual([]); + expect(fields({ title: 'Folio' })).toEqual([]); + expect(fields(null)).toEqual([]); + }); + + it('refuses an accent resolveTheme would replace, and only that', () => { + for (const accent of ['rot', 'red', 'rgb(230 0 18)', '#12345']) { + expect(fields({ theme: { accent } })).toEqual(['theme.accent']); + expect(resolveTheme({ preset: 'minimal', accent }).accent).toBe( + resolveTheme('minimal').accent, + ); + } + for (const accent of ['#e60012', '#FFF', ' #e60012 ', '#e60012cc', '']) { + expect(fields({ theme: { accent } })).toEqual([]); + } + // A preset name alone carries no accent. + expect(fields({ theme: 'minimal' })).toEqual([]); + }); + + it('refuses a site URL normaliseSiteUrl cannot use', () => { + for (const url of ['folio.example', 'ftp://folio.example', 'javascript:alert(1)']) { + expect(fields({ url })).toEqual(['url']); + expect(normaliseSiteUrl(url)).toBeNull(); + } + for (const url of ['https://folio.example', 'http://folio.example/sub/', '', ' ']) { + expect(fields({ url })).toEqual([]); + } + }); + + it('refuses grid values outside the bounds getConfig clamps to', () => { + expect(fields({ grid: { columns: 9 } })).toEqual(['grid.columns']); + expect(fields({ grid: { columns: 0 } })).toEqual(['grid.columns']); + expect(fields({ grid: { columns: 2.5 } })).toEqual(['grid.columns']); + expect(fields({ grid: { gap: 49 } })).toEqual(['grid.gap']); + expect(fields({ grid: { gap: -1 } })).toEqual(['grid.gap']); + expect(fields({ grid: { columns: 1, gap: 0 } })).toEqual([]); + expect(fields({ grid: { columns: 6, gap: 48 } })).toEqual([]); + }); +}); diff --git a/lib/__tests__/siteNav.test.ts b/lib/__tests__/siteNav.test.ts new file mode 100644 index 0000000..29fad57 --- /dev/null +++ b/lib/__tests__/siteNav.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { siteNavLinks, type SiteNavInput } from '../siteNav'; +import { getDictionary } from '../i18n'; + +const t = getDictionary('en'); + +const base: SiteNavInput = { + menu: [ + { key: 'sp-travel', href: '/travel', label: 'Travel' }, + { key: 'page-prices', href: '/prices', label: 'Prices' }, + { key: 'sp-people', href: '/people', label: 'People' }, + ], + subpageAlbumCounts: new Map([ + ['travel', 3], + ['people', 1], + ]), + albums: [{ id: 'a1', slug: 'birds', albumName: 'Birds', assetCount: 12 }], + hasJournal: true, + aboutEnabled: true, + map: true, +}; + +describe('siteNavLinks', () => { + it('orders subpages and pages, albums, Journal, About, Map (#696)', () => { + expect(siteNavLinks(base, t).map((l) => l.href)).toEqual([ + '/travel', + '/prices', + '/people', + '/birds', + '/journal', + '/about', + '/map', + ]); + }); + + it('leaves out Journal, About and Map when they are off', () => { + const links = siteNavLinks({ ...base, hasJournal: false, aboutEnabled: false, map: false }, t); + expect(links.map((l) => l.href)).not.toContain('/about'); + expect(links.map((l) => l.href)).not.toContain('/journal'); + expect(links.map((l) => l.href)).not.toContain('/map'); + }); + + it('attaches counts to subpages and albums only', () => { + const byHref = new Map(siteNavLinks(base, t).map((l) => [l.href, l.count])); + expect(byHref.get('/travel')).toBe('3 albums'); + expect(byHref.get('/people')).toBe('1 album'); + expect(byHref.get('/birds')).toBe('12 photos'); + expect(byHref.get('/prices')).toBeUndefined(); + expect(byHref.get('/about')).toBeUndefined(); + }); + + it('is empty for a site with nothing to link', () => { + expect( + siteNavLinks({ menu: [], albums: [], hasJournal: false, aboutEnabled: false, map: false }, t), + ).toEqual([]); + }); +}); diff --git a/lib/__tests__/siteShape.test.ts b/lib/__tests__/siteShape.test.ts new file mode 100644 index 0000000..6a40b10 --- /dev/null +++ b/lib/__tests__/siteShape.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@/lib/config', () => ({ + getConfig: () => ({ + subpages: [], + standaloneAlbums: [], + aboutEnabled: false, + map: false, + }), +})); + +vi.mock('@/lib/immich', () => ({ + immich: { getAlbums: async () => [] }, +})); + +vi.mock('@/lib/auth', () => ({ + isProtected: () => false, + isSiteLocked: () => false, +})); + +vi.mock('@/lib/admin/journal-service', () => ({ + listJournalEntries: async () => [ + { slug: 'open', frontmatter: { title: 'Open' } }, + { slug: 'locked', frontmatter: { title: 'Locked', password: 'scrypt:salt:hash' } }, + { slug: 'wip', frontmatter: { title: 'Draft', draft: true } }, + ], +})); + +vi.mock('@/lib/admin/pages-service', () => ({ + listPages: async () => [], +})); + +import { buildSiteShape } from '@/lib/siteShape'; +import { publicPaths } from '@/lib/publicPages'; + +describe('buildSiteShape → publicPaths (the sitemap)', () => { + it('lists a public journal entry', async () => { + expect(publicPaths(await buildSiteShape())).toContain('/journal/open'); + }); + + it('leaves out a password-protected journal entry, as /journal does', async () => { + expect(publicPaths(await buildSiteShape())).not.toContain('/journal/locked'); + }); + + it('leaves out a journal draft', async () => { + expect(publicPaths(await buildSiteShape())).not.toContain('/journal/wip'); + }); +}); diff --git a/lib/__tests__/slug-field.test.ts b/lib/__tests__/slug-field.test.ts new file mode 100644 index 0000000..0678c2d --- /dev/null +++ b/lib/__tests__/slug-field.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { isValidSlug, sanitizeSlug, slugFieldValue } from '../journal'; + +/** What a controlled slug input shows after each keystroke of `text`. */ +function typeInto(text: string): string { + let value = ''; + for (const ch of text) value = slugFieldValue(value + ch); + return value; +} + +describe('slugFieldValue — a slug field while it is typed', () => { + it('lets a hyphen be typed between two words', () => { + // sanitizeSlug() on every keystroke dropped the trailing "-", so the next + // letter was glued on: "about-us" came out as "aboutus". + expect(typeInto('about-us')).toBe('about-us'); + expect(typeInto('about us')).toBe('about-us'); + }); + + it('can be emptied instead of jumping to "untitled"', () => { + expect(slugFieldValue('')).toBe(''); + }); + + it('still refuses what a slug cannot hold', () => { + expect(slugFieldValue('Preise & Pakete!')).toBe('preise-pakete-'); + expect(slugFieldValue('--a//b')).toBe('a-b'); + }); + + it('becomes the stored slug through sanitizeSlug()', () => { + for (const input of ['about-', 'About Us ', ' a--b ', 'x_y']) { + const finished = sanitizeSlug(slugFieldValue(input)); + expect(finished).toBe(sanitizeSlug(input)); + expect(isValidSlug(finished)).toBe(true); + } + }); +}); + +describe('sanitizeSlug — accented letters', () => { + it('keeps the base letter instead of splitting the word', () => { + // "für" used to become "f-r": every non-ASCII letter turned into a hyphen. + expect(sanitizeSlug('Preise für Hochzeiten')).toBe('preise-fur-hochzeiten'); + expect(sanitizeSlug('Über mich')).toBe('uber-mich'); + expect(sanitizeSlug('Café Olé')).toBe('cafe-ole'); + expect(sanitizeSlug('Straße')).toBe('strasse'); + }); + + it('agrees with the slug field', () => { + expect(typeInto('Über mich')).toBe('uber-mich'); + }); + + it('still falls back for a title without any Latin letter', () => { + expect(sanitizeSlug('家族')).toBe('untitled'); + }); +}); diff --git a/lib/__tests__/yaml-write.test.ts b/lib/__tests__/yaml-write.test.ts index 2c41a02..ef98629 100644 --- a/lib/__tests__/yaml-write.test.ts +++ b/lib/__tests__/yaml-write.test.ts @@ -90,7 +90,7 @@ describe('writeYamlFile backup failure', () => { beforeEach(() => vi.clearAllMocks()); it('still saves a brand-new file with no backup', async () => { - await expect(writeGalleryYaml(gallery)).resolves.toBeUndefined(); + await expect(writeGalleryYaml(gallery)).resolves.toMatch(/^[0-9a-f]{32}$/); expect(fs.copyFile).not.toHaveBeenCalled(); }); diff --git a/lib/__tests__/zipArchive.test.ts b/lib/__tests__/zipArchive.test.ts new file mode 100644 index 0000000..d0fb2e5 --- /dev/null +++ b/lib/__tests__/zipArchive.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +/** + * Resource bounds of the ZIP-of-originals stream: how many archives one client + * may hold open at once, that every way an archive can end gives its slot + * back, and that a client which stops reading loses the archive (and its + * upstream Immich body) instead of pinning both for as long as it likes. + */ + +vi.mock('@/lib/immich', () => ({ immich: { streamAsset: vi.fn() } })); +vi.mock('@/lib/rate-limit', () => ({ + getClientIp: vi.fn((req: NextRequest) => req.headers.get('x-test-ip') ?? 'unknown'), +})); + +import { + ARCHIVE_STALL_TIMEOUT_MS, + MAX_CONCURRENT_ARCHIVES, + __archivesInFlight, + withArchiveSlot, +} from '@/lib/zipArchive'; +import { immich, type ImmichAsset } from '@/lib/immich'; + +const mockStream = immich.streamAsset as unknown as ReturnType; + +const ASSETS = [ + { id: 'a1', type: 'IMAGE', originalFileName: 'a1.jpg' }, + { id: 'a2', type: 'IMAGE', originalFileName: 'a2.jpg' }, +] as unknown as ImmichAsset[]; + +let ipSeq = 0; +/** A fresh client per test, so slots can never leak from one test into another. */ +function freshIp(): string { + ipSeq++; + return `203.0.113.${ipSeq}`; +} + +const req = (ip: string) => + new NextRequest('http://localhost/api/download/tok/archive', { headers: { 'x-test-ip': ip } }); + +/** Start an archive of `assets` for `ip` through the slot wrapper. */ +function start(ip: string, assets: ImmichAsset[] = ASSETS) { + return withArchiveSlot(req(ip), async (stream) => stream('Album', assets)); +} + +/** + * An Immich originals body that produces `chunks` × 64 KB on demand and records + * whether it was cancelled — i.e. whether the upstream socket was let go. + */ +function upstream(chunks: number) { + const state = { pulled: 0, cancelled: false }; + const stream = new ReadableStream({ + pull(controller) { + if (state.pulled >= chunks) { + controller.close(); + return; + } + state.pulled++; + controller.enqueue(new Uint8Array(64 * 1024).fill(state.pulled)); + }, + cancel() { + state.cancelled = true; + }, + }); + return { state, result: { stream, contentType: 'image/jpeg', contentLength: null } }; +} + +beforeEach(() => { + mockStream.mockReset(); + mockStream.mockImplementation(async () => upstream(2).result); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('archive concurrency cap', () => { + it(`refuses the ${MAX_CONCURRENT_ARCHIVES + 1}th open archive of one client with a 429`, async () => { + const ip = freshIp(); + const open = []; + for (let i = 0; i < MAX_CONCURRENT_ARCHIVES; i++) { + const res = await start(ip); + expect(res.status).toBe(200); + open.push(res); + } + expect(__archivesInFlight(ip)).toBe(MAX_CONCURRENT_ARCHIVES); + + const handler = vi.fn(); + const refused = await withArchiveSlot(req(ip), handler); + expect(refused.status).toBe(429); + expect(Number(refused.headers.get('retry-after'))).toBeGreaterThan(0); + // Refused before the handler, so before any Immich request. + expect(handler).not.toHaveBeenCalled(); + + // Another client is unaffected. + const other = freshIp(); + const res = await start(other); + expect(res.status).toBe(200); + + await Promise.all([...open, res].map((r) => r.body!.cancel())); + }); + + it('gives the slot back when the archive is read to the end', async () => { + const ip = freshIp(); + const res = await start(ip); + const bytes = new Uint8Array(await res.arrayBuffer()); + expect(bytes[0]).toBe(0x50); // 'P' + await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0)); + }); + + it('gives the slot back when the archive fails', async () => { + const ip = freshIp(); + mockStream.mockRejectedValue(new Error('Immich went away')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = await start(ip); + await expect(res.arrayBuffer()).rejects.toThrow(); + await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0)); + }); + + it('gives the slot back, and lets go of Immich, when the download is cancelled', async () => { + const ip = freshIp(); + const origin = upstream(1000); + mockStream.mockResolvedValue(origin.result); + const res = await start(ip); + const reader = res.body!.getReader(); + await reader.read(); + await reader.cancel(); + await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0)); + await vi.waitFor(() => expect(origin.state.cancelled).toBe(true)); + }); + + it('gives the slot back when the route refuses or throws instead of streaming', async () => { + const ip = freshIp(); + const refused = await withArchiveSlot(req(ip), async () => new Response(null, { status: 404 })); + expect(refused.status).toBe(404); + expect(__archivesInFlight(ip)).toBe(0); + + await expect( + withArchiveSlot(req(ip), async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + expect(__archivesInFlight(ip)).toBe(0); + }); + + it('never releases a slot twice', async () => { + const ip = freshIp(); + const first = await start(ip); + const second = await start(ip); + await first.arrayBuffer(); + await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(1)); + // A late cancel on the finished body must not free the other archive's slot. + await first.body?.cancel().catch(() => {}); + expect(__archivesInFlight(ip)).toBe(1); + await second.body!.cancel(); + await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0)); + }); +}); + +describe('archive stall timeout', () => { + it('tears down the archive and the upstream body when the client stops reading', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const ip = freshIp(); + const origin = upstream(1000); + mockStream.mockResolvedValue(origin.result); + + const res = await start(ip); + const reader = res.body!.getReader(); + await reader.read(); + + // Still within the window: nothing happens. + await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS - 1_000); + expect(origin.state.cancelled).toBe(false); + expect(__archivesInFlight(ip)).toBe(1); + + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => expect(origin.state.cancelled).toBe(true)); + expect(__archivesInFlight(ip)).toBe(0); + // The response is ended with an error rather than left open. The one chunk + // queued before the stall may still be handed out first. + await expect( + (async () => { + for (;;) if ((await reader.read()).done) return; + })(), + ).rejects.toThrow(/stalled/); + }); + + it('does not cut a slow download that keeps reading', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const ip = freshIp(); + const origin = upstream(128); + mockStream.mockResolvedValue(origin.result); + + const res = await start(ip, [ASSETS[0]]); + const reader = res.body!.getReader(); + let bytes = 0; + let elapsed = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + // Well under the stall timeout per read, far over it in total. + await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS / 2); + elapsed += ARCHIVE_STALL_TIMEOUT_MS / 2; + } + + expect(elapsed).toBeGreaterThan(ARCHIVE_STALL_TIMEOUT_MS * 3); + expect(bytes).toBeGreaterThan(128 * 64 * 1024); + expect(origin.state.cancelled).toBe(false); + await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0)); + }); +}); diff --git a/lib/admin/assetSelection.ts b/lib/admin/assetSelection.ts new file mode 100644 index 0000000..3fbf2af --- /dev/null +++ b/lib/admin/assetSelection.ts @@ -0,0 +1,42 @@ +/** + * Selection state of the asset picker's multi-select mode (#602), as plain + * functions so it can be tested without rendering the picker. + * + * The selection is an ordered list: the order you click in is the order the + * photos land in (hero list, photo grid), so a Set would lose information. + */ + +export interface SelectionRules { + /** Assets the caller already uses; they cannot be picked again. */ + disabled?: ReadonlySet; + /** Most assets the caller can take; undefined means no limit. */ + max?: number; +} + +/** + * The selection after toggling `id`: removed when present, appended when not, + * unchanged when it is disabled or the limit is reached. With `max: 1` a new + * pick replaces the old one, which is how a single slot should feel. + */ +export function toggleSelection( + selected: readonly string[], + id: string, + rules: SelectionRules = {}, +): string[] { + if (selected.includes(id)) return selected.filter((s) => s !== id); + if (rules.disabled?.has(id)) return [...selected]; + if (rules.max === 1) return [id]; + if (rules.max !== undefined && selected.length >= rules.max) return [...selected]; + return [...selected, id]; +} + +/** Whether another asset can still be added without deselecting one first. */ +export function canSelectMore(selected: readonly string[], max?: number): boolean { + return max === undefined || max === 1 || selected.length < max; +} + +/** Client-side filter for lists the server returns whole (an album's assets). */ +export function matchesQuery(asset: { originalFileName: string }, query: string): boolean { + const q = query.trim().toLowerCase(); + return q === '' || asset.originalFileName.toLowerCase().includes(q); +} diff --git a/lib/admin/contentVersion.ts b/lib/admin/contentVersion.ts new file mode 100644 index 0000000..1d3c44b --- /dev/null +++ b/lib/admin/contentVersion.ts @@ -0,0 +1,127 @@ +/** + * Concurrent-edit detection for the admin's content files (#601). + * + * Every editor holds a full snapshot of the file it edits and PUTs all of it, + * so without a check the tab that saves second silently reverts whatever the + * first one changed — as does a panel left open across a hand edit or a backup + * restore. Each GET therefore hands out a version of the file it read, the PUT + * sends it back in `If-Match`, and the write is refused with 409 when the file + * on disk is no longer that version. + * + * The version is a hash of the bytes, not the mtime: the mtime has a + * resolution the filesystem chooses (a second on some), survives a copy that + * changes the content (`cp -p`, a restore from a backup of equal age) and + * changes on a `touch` that does not. The files are small, so hashing one per + * save costs nothing that matters. + * + * A PUT without `If-Match` is written as before. That keeps an older admin tab + * (loaded before the upgrade) and scripts working, and the check is a guard + * against accidents, not an access control: an operator who wants to overwrite + * can always reload and save. `If-Match: *` means the same — write regardless. + */ + +import crypto from 'crypto'; +import fs from 'fs/promises'; +import { NextResponse } from 'next/server'; + +/** The version of a file that does not exist. A save expecting it creates the file. */ +export const ABSENT_VERSION = 'absent'; + +/** + * Version of some content; null content means "no file". A string is hashed + * as its UTF-8 bytes, which is what a save writes — so it fits content about + * to be written, not text decoded from a file (see readVersioned). + */ +export function versionOf(content: string | Buffer | null): string { + if (content === null) return ABSENT_VERSION; + return crypto.createHash('sha256').update(content).digest('hex').slice(0, 32); +} + +/** + * Read a text file together with the version of its bytes, for an editor's + * GET. The version has to come from the bytes, never from the decoded text: + * a file that is not valid UTF-8 (a Latin-1 "Über" from a hand edit) decodes + * lossily, the text re-encodes to other bytes, and a version taken from it + * would never equal what `fileVersion()` computes at save time — every save + * of that file would be refused as a conflict. Throws ENOENT like readFile. + */ +export async function readVersioned(filePath: string): Promise<{ text: string; version: string }> { + const bytes = await fs.readFile(filePath); + return { text: bytes.toString('utf8'), version: versionOf(bytes) }; +} + +/** Version of the file at `filePath` as it is on disk now. */ +export async function fileVersion(filePath: string): Promise { + try { + return versionOf(await fs.readFile(filePath)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ABSENT_VERSION; + throw err; + } +} + +/** The file changed on disk since the editor loaded it. */ +export class VersionConflictError extends Error { + constructor(readonly currentVersion: string) { + super('The file was changed elsewhere since it was loaded.'); + this.name = 'VersionConflictError'; + } +} + +/** + * Throw a VersionConflictError when `filePath` is not at `baseVersion`. + * An undefined base version (no `If-Match`) skips the check. + */ +export async function assertVersion( + filePath: string, + baseVersion: string | undefined, +): Promise { + if (baseVersion === undefined) return; + const current = await fileVersion(filePath); + if (current !== baseVersion) throw new VersionConflictError(current); +} + +/** + * The base version a PUT sent, or undefined for "no check". Accepts the + * quoted ETag form (`"abc"`, `W/"abc"`) as well as a bare token; `*` and an + * empty header mean no check. + */ +export function baseVersionFrom(request: Request): string | undefined { + const raw = request.headers.get('if-match')?.trim(); + if (!raw || raw === '*') return undefined; + return raw.replace(/^W\//, '').replace(/^"(.*)"$/, '$1'); +} + +/** The header form of a version. */ +export function etag(version: string): string { + return `"${version}"`; +} + +/** The 409 every content route answers a stale save with. */ +export function conflictResponse(currentVersion: string): NextResponse { + return NextResponse.json( + { + error: 'This was changed elsewhere since you opened it.', + conflict: true, + version: currentVersion, + }, + { status: 409, headers: { ETag: etag(currentVersion) } }, + ); +} + +/** + * Run content writes one at a time within this process. + * + * The version check and the write it guards must not interleave with another + * save, or two tabs saving in the same instant would both pass the check. A + * single queue for all content files is enough — admin saves are rare and + * short — and it also serialises a rename's write-then-delete. Like the rate + * limiter it is per process; Folio runs as one. + */ +let queue: Promise = Promise.resolve(); + +export function serializeContentWrite(fn: () => Promise): Promise { + const run = queue.then(fn, fn); + queue = run.catch(() => {}); + return run; +} diff --git a/lib/admin/doctor.ts b/lib/admin/doctor.ts index 36720b8..03c43a4 100644 --- a/lib/admin/doctor.ts +++ b/lib/admin/doctor.ts @@ -24,6 +24,11 @@ export interface DoctorFinding { * diagnostics page can link straight to the album in the page builder. */ albumIds?: string[]; + /** + * The settings section (`/admin/settings/
`) that holds the fix, when + * it depends on the finding rather than on its id. + */ + settingsSection?: string; } /** The worst level present — what the status badge should show. */ @@ -558,6 +563,53 @@ export function checkContact( }; } +/** Where each value checked by `validateSettingValues` is edited. */ +const SETTING_SECTIONS: Record = { + url: 'seo', + 'theme.accent': 'theme', + 'grid.columns': 'grid', + 'grid.gap': 'grid', +}; + +/** Human names for the same fields, for the finding's text. */ +const SETTING_LABELS: Record = { + url: 'Site URL', + 'theme.accent': 'accent colour', + 'grid.columns': 'grid columns', + 'grid.gap': 'grid gap', +}; + +/** + * Values in settings.yaml the site does not use as written — an accent that is + * not hex, a column count outside 1–6, a site URL that is not a full address. + * The resolvers fall back or clamp, so nothing breaks and nothing says so; the + * panel now refuses to save them, but a hand-edited file or one saved before + * that still holds them. Takes `validateSettingValues(settings.yaml)`. + */ +export function checkSettingValues(errors: Array<{ field: string }>): DoctorFinding { + if (errors.length === 0) { + return { + id: 'settings-values', + level: 'ok', + title: 'Settings are used as written', + detail: 'Site URL, accent colour and grid values in settings.yaml are all valid.', + }; + } + const names = errors.map((e) => SETTING_LABELS[e.field] ?? e.field); + return { + id: 'settings-values', + level: 'warn', + title: + errors.length === 1 + ? `The ${names[0]} setting is ignored` + : `${errors.length} settings are ignored`, + detail: + `settings.yaml holds a value the site cannot use for: ${names.join(', ')}. ` + + 'It falls back to the default instead. Open the setting to see what it expects.', + settingsSection: SETTING_SECTIONS[errors[0].field], + }; +} + /** * A site that needs an Impressum almost always needs a privacy policy too * (Art. 13 GDPR). Returns null while there is neither an Impressum nor a @@ -637,3 +689,60 @@ export function checkImmichCalls(results: Array<{ endpoint: string; ok: boolean detail: results.map((r) => r.endpoint).join(', '), }; } + +export interface PageSlugProblem { + slug: string; + /** One sentence from describeCollision(). */ + reason: string; +} + +/** + * Content pages (#722): a `- page:` reference in gallery.yaml whose file does + * not exist puts nothing in the menu, and a page whose slug is already taken + * is shadowed by the subpage or album — or, for a built-in route, never + * reached at all. Returns null while there are no pages and no references. + */ +export function checkContentPages(p: { + /** Slugs referenced from gallery.yaml. */ + menuRefs: string[]; + /** Slugs of the files in content/pages/. */ + pages: string[]; + collisions: PageSlugProblem[]; +}): DoctorFinding | null { + if (!p.menuRefs.length && !p.pages.length) return null; + const existing = new Set(p.pages); + const missing = p.menuRefs.filter((slug) => !existing.has(slug)); + const missingList = missing.map((s) => `"${s}"`).join(', '); + + if (p.collisions.length) { + const n = p.collisions.length; + return { + id: 'content-pages', + level: 'error', + title: `${n} content ${n === 1 ? 'page has' : 'pages have'} a slug that is already taken`, + detail: + p.collisions.map((c) => c.reason).join(' ') + + ' Rename the page in Pages so visitors can reach it.' + + (missing.length ? ` The menu also lists missing pages: ${missingList}.` : ''), + }; + } + + if (missing.length) { + return { + id: 'content-pages', + level: 'warn', + title: `The menu lists ${missing.length} missing ${missing.length === 1 ? 'page' : 'pages'}`, + detail: + `gallery.yaml references ${missingList}, but content/pages/ has no such file. ` + + 'The menu skips the entry; remove the reference or create the page.', + }; + } + + const n = p.pages.length; + return { + id: 'content-pages', + level: 'ok', + title: `${n} content ${n === 1 ? 'page' : 'pages'}, every slug free`, + detail: `${p.menuRefs.length} of them in the menu.`, + }; +} diff --git a/lib/admin/envLocks.ts b/lib/admin/envLocks.ts new file mode 100644 index 0000000..a5e1a82 --- /dev/null +++ b/lib/admin/envLocks.ts @@ -0,0 +1,78 @@ +/** + * Settings fields that an environment variable overrides (#605). + * + * Only fields where the environment actually *wins* belong here — an edit in + * the panel would save, report success and then do nothing. `SITE_TITLE`, + * `SITE_SUBTITLE` and `SITE_URL` are deliberately absent: lib/config/index.ts + * resolves them as `settings.X ?? env.X`, so the field takes precedence and + * editing it works (see lib/siteUrl.ts). + * + * Client-safe: no `fs`, no `lib/env` import. The route passes the parsed env + * in, and only the variable *names* ever reach the browser. + */ + +/** Dotted settings.yaml path → the environment variable that overrides it. */ +export const ENV_LOCKS = { + sitePassword: 'SITE_PASSWORD', + 'contact.notifyUrl': 'CONTACT_NOTIFY_URL', +} as const; + +export type EnvLockedKey = keyof typeof ENV_LOCKS; + +/** Locked path → variable name. Never carries the variable's value. */ +export type EnvLocks = Partial>; + +/** The locks in effect for this environment: paths whose variable is set. */ +export function resolveEnvLocks(env: object): EnvLocks { + const source = env as Record; + const locks: EnvLocks = {}; + for (const [path, name] of Object.entries(ENV_LOCKS) as [EnvLockedKey, string][]) { + const value = source[name]; + if (typeof value === 'string' && value.trim() !== '') locks[path] = name; + } + return locks; +} + +function getPath(obj: unknown, path: string[]): unknown { + let cur = obj; + for (const key of path) { + if (!cur || typeof cur !== 'object') return undefined; + cur = (cur as Record)[key]; + } + return cur; +} + +/** + * The incoming settings with every locked path reset to what settings.yaml + * already holds. The panel disables those fields, so this only matters for a + * hand-crafted request or a stale draft — but a locked field must not change + * through a save whose effect the operator could never see. + */ +export function keepLockedValues( + incoming: T, + stored: object | null | undefined, + locks: EnvLocks, +): T { + const out = structuredClone(incoming) as Record; + for (const dotted of Object.keys(locks)) { + const path = dotted.split('.'); + const last = path[path.length - 1]; + const previous = getPath(stored, path); + let parent: Record | null = out; + for (const key of path.slice(0, -1)) { + const next: unknown = parent[key]; + if (!next || typeof next !== 'object') { + if (previous === undefined) { + parent = null; + break; + } + parent[key] = {}; + } + parent = parent[key] as Record; + } + if (!parent) continue; + if (previous === undefined) delete parent[last]; + else parent[last] = previous; + } + return out as T; +} diff --git a/lib/admin/journal-service.ts b/lib/admin/journal-service.ts index 5b80c2b..dc624ea 100644 --- a/lib/admin/journal-service.ts +++ b/lib/admin/journal-service.ts @@ -7,6 +7,8 @@ import fs from 'fs/promises'; import nodeFs from 'fs'; import path from 'path'; import { atomicWrite } from '../atomicWrite'; +import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion'; +import { ParsedFileCache } from './parsedFileCache'; import { parseJournalMarkdown, calculateReadingTime, @@ -184,10 +186,30 @@ export function loadEssayFromFile(filename: string): ParsedJournal | null { } } +/** What a listing needs from one file — everything but the name-derived fields. */ +type EntryFacts = Omit; + +/** + * Parsed listing facts per file, reused while the file is unchanged: every + * public page render lists the journal for the header nav (see + * parsedFileCache.ts). + */ +const summaryCache = new ParsedFileCache((content) => { + const parsed = parseJournalMarkdown(content); + const { words, minutes } = calculateReadingTime(content); + return { + frontmatter: parsed.frontmatter, + excerpt: extractExcerpt(parsed), + wordCount: words, + readingTimeMinutes: minutes, + }; +}); + /** List all journal entries */ export async function listJournalEntries(): Promise { const entries: JournalEntrySummary[] = []; const seenSlugs = new Set(); + const listed = new Set(); const scanDir = async (dir: string) => { try { @@ -205,19 +227,10 @@ export async function listJournalEntries(): Promise { seenSlugs.add(slug); try { - const content = await fs.readFile(path.join(dir, file), 'utf8'); - const parsed = parseJournalMarkdown(content); - const { words, minutes } = calculateReadingTime(content); - const excerpt = extractExcerpt(parsed); - - entries.push({ - slug, - filename: file, - frontmatter: parsed.frontmatter, - excerpt, - wordCount: words, - readingTimeMinutes: minutes, - }); + const filePath = path.join(dir, file); + listed.add(filePath); + const summary = await summaryCache.read(filePath); + entries.push({ slug, filename: file, ...summary }); } catch (err) { console.error(`[Journal] Failed to parse ${file}:`, err); } @@ -229,15 +242,19 @@ export async function listJournalEntries(): Promise { await scanDir(JOURNAL_DIR); await scanDir(LEGACY_ESSAYS_DIR); + summaryCache.retain(listed); // Sort: descending by date, fallback to title/slug return entries.sort((a, b) => { const dateA = a.frontmatter.date || ''; const dateB = b.frontmatter.date || ''; - if (dateA && dateB) return dateB.localeCompare(dateA); + if (dateA && dateB) return dateB.localeCompare(dateA) || a.slug.localeCompare(b.slug); if (dateA) return -1; if (dateB) return 1; - return (a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug); + return ( + (a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug) || + a.slug.localeCompare(b.slug) + ); }); } @@ -246,14 +263,16 @@ export async function readJournalEntry(slug: string): Promise<{ slug: string; rawMarkdown: string; parsed: ParsedJournal; + /** Version of the file's bytes (#601), for the editor's If-Match. */ + version: string; } | null> { const filePath = resolveJournalFilePath(slug); if (!filePath) return null; try { - const rawMarkdown = await fs.readFile(filePath, 'utf8'); + const { text: rawMarkdown, version } = await readVersioned(filePath); const parsed = parseJournalMarkdown(rawMarkdown); - return { slug, rawMarkdown, parsed }; + return { slug, rawMarkdown, parsed, version }; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err; @@ -358,3 +377,28 @@ export async function deleteJournalEntry(slug: string): Promise { if (deletedAny) console.log(`[Journal] 🗑️ Deleted ${filename}`); return deletedAny; } + +/** + * The editor's save (#601): write `slug`, refusing with a VersionConflictError + * when the file the editor loaded — `fromSlug`'s, for a rename — is no longer + * at `baseVersion`. A rename writes the new file and then deletes the old one. + * Check, write and delete run in the content write queue, so another save + * cannot land between them. Returns the version of what was written. + */ +export function saveJournalEntry( + slug: string, + rawMarkdown: string, + options: { baseVersion?: string; fromSlug?: string } = {}, +): Promise { + const fromSlug = options.fromSlug ?? slug; + return serializeContentWrite(async () => { + if (options.baseVersion !== undefined) { + const loaded = resolveJournalFilePath(fromSlug); + if (!loaded) throw new Error(`Invalid journal entry slug: "${fromSlug}"`); + await assertVersion(loaded, options.baseVersion); + } + await writeJournalEntry(slug, rawMarkdown); + if (fromSlug !== slug) await deleteJournalEntry(fromSlug); + return versionOf(rawMarkdown); + }); +} diff --git a/lib/admin/pageRefs.ts b/lib/admin/pageRefs.ts new file mode 100644 index 0000000..93c5278 --- /dev/null +++ b/lib/admin/pageRefs.ts @@ -0,0 +1,59 @@ +/** + * Keep gallery.yaml's `- page: ` references in step with the page files + * (#722): a deleted page leaves the menu, a renamed one keeps its place. + * + * Server only. Writes go through writeGalleryYaml(), so the previous + * gallery.yaml is backed up first like every other admin save. + */ + +import { readGalleryYamlVersioned, writeGalleryYaml } from './yaml-service'; +import { VersionConflictError } from './contentVersion'; +import { invalidateConfigCache, isPageRef } from '../config'; + +/** + * Pure: the `subpages:` list with every reference to `from` replaced by `to`, + * or dropped when `to` is null. Returns the same list when nothing matched. + */ +export function rewritePageRefs(subpages: T[], from: string, to: string | null): T[] { + if (!subpages.some((e) => isPageRef(e) && e.page === from)) return subpages; + return subpages.flatMap((entry) => { + if (!isPageRef(entry) || entry.page !== from) return [entry]; + return to === null ? [] : [{ ...entry, page: to } as T]; + }); +} + +/** gallery.yaml's version before and after a menu rewrite (#601). */ +export interface GalleryVersionChange { + from: string; + to: string; +} + +/** + * Apply rewritePageRefs() to gallery.yaml on disk. Returns the version change + * when the file changed, null when it did not. + * + * The page builder is open while this runs and holds gallery.yaml's version. + * With `from` and `to` it can tell "only this rewrite happened since I loaded" + * (take the new version) from "someone else saved too" (keep the old one, so + * its next save is refused rather than clobbering). The write itself is + * checked against the version read here, and re-read on a conflict, so a + * builder save racing the rewrite is not overwritten either. + */ +export async function updatePageRefs( + from: string, + to: string | null, +): Promise { + for (let attempt = 0; ; attempt++) { + const { data: gallery, version } = await readGalleryYamlVersioned(); + if (!gallery || !Array.isArray(gallery.subpages)) return null; + const next = rewritePageRefs(gallery.subpages, from, to); + if (next === gallery.subpages) return null; + try { + const written = await writeGalleryYaml({ ...gallery, subpages: next }, version); + invalidateConfigCache(); + return { from: version, to: written }; + } catch (err) { + if (!(err instanceof VersionConflictError) || attempt >= 2) throw err; + } + } +} diff --git a/lib/admin/pageSlugs.ts b/lib/admin/pageSlugs.ts new file mode 100644 index 0000000..98d959e --- /dev/null +++ b/lib/admin/pageSlugs.ts @@ -0,0 +1,27 @@ +/** + * The slugs a content page must not take (#722), gathered from the live + * configuration: subpages, standalone albums and journal entries. The fixed + * routes are checked by pageSlugCollision() itself. + * + * Server only. Album slugs need Immich; when it cannot answer, the album list + * is empty rather than the whole check failing — a save during an Immich + * outage should not be refused for a reason nobody can see. + */ + +import { getConfig } from '../config'; +import { immich } from '../immich'; +import { listJournalEntries } from './journal-service'; +import type { SlugTakenBy } from '../pages'; + +export async function takenPageSlugs(): Promise { + const config = getConfig(); + const [albums, journal] = await Promise.all([ + immich.getStandaloneAlbums().catch(() => []), + listJournalEntries().catch(() => []), + ]); + return { + subpages: config.subpages.map((sp) => sp.slug), + albums: albums.map((a) => a.slug), + journal: journal.map((e) => e.slug), + }; +} diff --git a/lib/admin/pages-service.ts b/lib/admin/pages-service.ts new file mode 100644 index 0000000..1ceab91 --- /dev/null +++ b/lib/admin/pages-service.ts @@ -0,0 +1,285 @@ +/** + * Server-side storage for content pages (#722): `content/pages/.md`. + * + * Mirrors journal-service.ts — the same slug rule, the same containment check + * on every path, atomic writes, and rotating backups in + * `content/pages/.backups/` (ten saves per page; delete and pre-restore + * snapshots are never pruned). + */ + +import fs from 'fs/promises'; +import nodeFs from 'fs'; +import path from 'path'; +import { atomicWrite } from '../atomicWrite'; +import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion'; +import { isValidSlug, parseJournalMarkdown, type ParsedJournal } from '../journal'; +import type { PageSummary } from '../pages'; +import { ParsedFileCache } from './parsedFileCache'; + +const MAX_BACKUPS = 10; + +/** + * Join a filename onto a directory, or return null if the result would escape + * it. isValidSlug() already rejects dots and separators; this is the second + * lock, and the barrier the taint analysis in CI can see. + */ +function containedPath(dir: string, filename: string): string | null { + const base = path.resolve(dir); + const resolved = path.resolve(base, filename); + if (resolved !== base && !resolved.startsWith(base + path.sep)) return null; + return resolved; +} + +/** + * Resolved per call rather than at import: tests point the service at a + * temporary directory by changing the working directory. + */ +function pagesDir(): string { + return path.join(process.cwd(), 'content', 'pages'); +} +function backupDir(): string { + return path.join(pagesDir(), '.backups'); +} + +/** `.md.[.deleted|.pre-restore].bak` — see journal-service. */ +const PAGE_BACKUP_NAME = /^([\w-]+)\.md\.[\w-]+\.(?:(deleted|pre-restore)\.)?bak$/; + +export interface PageBackup { + filename: string; + slug: string; + kind: 'save' | 'deleted' | 'pre-restore'; +} + +/** The file a slug lives in, or null for a slug that is not one. */ +export function resolvePageFilePath(slug: string): string | null { + if (!isValidSlug(slug)) return null; + return containedPath(pagesDir(), `${slug}.md`); +} + +async function snapshotPage( + filePath: string, + filename: string, + kind?: 'deleted' | 'pre-restore', +): Promise { + await fs.mkdir(backupDir(), { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const suffix = kind ? `.${kind}` : ''; + const backupPath = containedPath(backupDir(), `${filename}.${timestamp}${suffix}.bak`); + if (!backupPath) { + throw new Error(`Refusing to write a backup outside ${backupDir()}: "${filename}"`); + } + await fs.copyFile(filePath, backupPath); +} + +async function prunePageBackups(filename: string): Promise { + const backups = (await fs.readdir(backupDir())) + .filter((f) => { + const match = PAGE_BACKUP_NAME.exec(f); + return match !== null && `${match[1]}.md` === filename && match[2] === undefined; + }) + .sort(); + for (const old of backups.slice(0, Math.max(0, backups.length - MAX_BACKUPS))) { + await fs.unlink(path.join(backupDir(), old)).catch(() => {}); + } +} + +/** Every page backup on disk, newest first. */ +export async function listPageBackups(): Promise { + let files: string[]; + try { + files = await fs.readdir(backupDir()); + } catch { + return []; + } + return files + .map((filename) => { + const match = PAGE_BACKUP_NAME.exec(filename); + if (!match) return null; + return { filename, slug: match[1], kind: (match[2] ?? 'save') as PageBackup['kind'] }; + }) + .filter((b): b is PageBackup => b !== null) + .sort((a, b) => b.filename.localeCompare(a.filename)); +} + +/** + * The listed frontmatter per file, reused while the file is unchanged: every + * public page render lists the pages for the header nav (see + * parsedFileCache.ts). + */ +const frontmatterCache = new ParsedFileCache((raw) => { + const { title, description, password, draft } = parseJournalMarkdown(raw).frontmatter; + return { title, description, password, draft }; +}); + +/** Every page, sorted by title. Unparseable files are skipped with a log line. */ +export async function listPages(): Promise { + let files: string[]; + try { + files = await fs.readdir(pagesDir()); + } catch { + return []; + } + const pages: PageSummary[] = []; + const listed = new Set(); + for (const file of files) { + if (!file.endsWith('.md')) continue; + const slug = file.slice(0, -3); + const filePath = resolvePageFilePath(slug); + if (!filePath) continue; + listed.add(filePath); + try { + pages.push({ slug, frontmatter: await frontmatterCache.read(filePath) }); + } catch (err) { + console.error(`[Pages] Failed to read ${file}:`, err); + } + } + frontmatterCache.retain(listed); + return pages.sort((a, b) => + (a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug), + ); +} + +/** Slugs of every page file, without parsing them. */ +export function listPageSlugsSync(): string[] { + try { + return nodeFs + .readdirSync(pagesDir()) + .filter((f) => f.endsWith('.md')) + .map((f) => f.slice(0, -3)) + .filter(isValidSlug); + } catch { + return []; + } +} + +export interface PageRecord { + slug: string; + rawMarkdown: string; + parsed: ParsedJournal; + /** Version of the file's bytes (#601), for the editor's If-Match. */ + version: string; +} + +/** One page, or null when there is no such file. */ +export async function readPage(slug: string): Promise { + const filePath = resolvePageFilePath(slug); + if (!filePath) return null; + try { + const { text: rawMarkdown, version } = await readVersioned(filePath); + return { slug, rawMarkdown, parsed: parseJournalMarkdown(rawMarkdown), version }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +/** Synchronous read, for the password lookup in lib/auth.ts. */ +export function readPageSync(slug: string): PageRecord | null { + const filePath = resolvePageFilePath(slug); + if (!filePath) return null; + try { + const bytes = nodeFs.readFileSync(filePath); + const rawMarkdown = bytes.toString('utf8'); + return { + slug, + rawMarkdown, + parsed: parseJournalMarkdown(rawMarkdown), + version: versionOf(bytes), + }; + } catch { + return null; + } +} + +/** Write a page atomically, snapshotting the previous version first. */ +export async function writePage(slug: string, rawMarkdown: string): Promise { + const filePath = resolvePageFilePath(slug); + if (!filePath) throw new Error(`Invalid page slug: "${slug}"`); + const filename = `${slug}.md`; + await fs.mkdir(pagesDir(), { recursive: true }); + + // Only ENOENT means "nothing to back up"; any other failure aborts the save + // before it overwrites the live file (the journal's #630). + let exists = true; + try { + await fs.access(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + exists = false; + } + if (exists) { + await snapshotPage(filePath, filename); + await prunePageBackups(filename); + } + + await atomicWrite(filePath, rawMarkdown); + console.log(`[Pages] ✅ Saved ${filename}`); +} + +/** + * Delete a page, keeping a `.deleted.bak` copy. The snapshot is not optional: + * when it cannot be written, the delete is refused. + */ +export async function deletePage(slug: string): Promise { + const filePath = resolvePageFilePath(slug); + if (!filePath) throw new Error(`Invalid page slug: "${slug}"`); + try { + await fs.access(filePath); + } catch { + return false; + } + await snapshotPage(filePath, `${slug}.md`, 'deleted'); + await fs.unlink(filePath); + console.log(`[Pages] 🗑️ Deleted ${slug}.md`); + return true; +} + +/** Restore a page from one of its backups, returning the restored slug. */ +export async function restorePageBackup(backupFilename: string): Promise { + const match = PAGE_BACKUP_NAME.exec(backupFilename); + if (!match || path.basename(backupFilename) !== backupFilename || !isValidSlug(match[1])) { + throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`); + } + const slug = match[1]; + const source = containedPath(backupDir(), backupFilename); + const target = resolvePageFilePath(slug); + if (!source || !target) { + throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`); + } + const content = await fs.readFile(source, 'utf8'); + try { + await fs.access(target); + await snapshotPage(target, `${slug}.md`, 'pre-restore'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + await fs.mkdir(pagesDir(), { recursive: true }); + await atomicWrite(target, content); + console.log(`[Pages] 🔄 Restored ${slug}.md from ${backupFilename}`); + return slug; +} + +/** + * The editor's save (#601): write `slug`, refusing with a VersionConflictError + * when the file the editor loaded — `fromSlug`'s, for a rename — is no longer + * at `baseVersion`. A rename writes the new file and then deletes the old one. + * Check, write and delete run in the content write queue, so another save + * cannot land between them. Returns the version of what was written. + */ +export function savePage( + slug: string, + rawMarkdown: string, + options: { baseVersion?: string; fromSlug?: string } = {}, +): Promise { + const fromSlug = options.fromSlug ?? slug; + return serializeContentWrite(async () => { + if (options.baseVersion !== undefined) { + const loaded = resolvePageFilePath(fromSlug); + if (!loaded) throw new Error(`Invalid page slug: "${fromSlug}"`); + await assertVersion(loaded, options.baseVersion); + } + await writePage(slug, rawMarkdown); + if (fromSlug !== slug) await deletePage(fromSlug); + return versionOf(rawMarkdown); + }); +} diff --git a/lib/admin/parsedFileCache.ts b/lib/admin/parsedFileCache.ts new file mode 100644 index 0000000..5af08ea --- /dev/null +++ b/lib/admin/parsedFileCache.ts @@ -0,0 +1,57 @@ +/** + * Per-file memo for the journal and page listings. + * + * `listJournalEntries()` and `listPages()` run on every public page render — + * the header nav asks whether a journal exists and which pages to link — and + * used to read and parse every markdown file each time. The parse is the + * expensive part and depends only on the file's bytes, so it is kept here, + * keyed on the file's identity and stamp: inode, size and mtime in + * nanoseconds. + * + * An admin save goes through atomicWrite (temp file + rename), which always + * produces a new inode, so a save is seen by the very next listing, in this + * module instance or any other. An edit in place changes mtime. The stat is + * taken before the read, so a write landing between the two at worst stores + * the new bytes under the old stamp — which the next stat no longer matches, + * so the file is simply read again. Stale bytes under a current stamp cannot + * happen. + * + * Values are handed out as copies: a caller that mutates an entry must not + * change what the next request sees. + */ + +import fs from 'fs/promises'; + +interface Cached { + stamp: string; + value: T; +} + +export class ParsedFileCache { + private readonly entries = new Map>(); + + constructor(private readonly parse: (raw: string) => T) {} + + /** + * Parsed contents of `filePath`, from the memo when the file is unchanged. + * Throws like `fs.readFile` for a missing or unreadable file, and like + * `parse` for one that does not parse — neither outcome is cached. + */ + async read(filePath: string): Promise { + const st = await fs.stat(filePath, { bigint: true }); + const stamp = `${st.ino}:${st.size}:${st.mtimeNs}`; + const hit = this.entries.get(filePath); + if (hit && hit.stamp === stamp) return structuredClone(hit.value); + + const value = this.parse(await fs.readFile(filePath, 'utf8')); + this.entries.set(filePath, { stamp, value }); + return structuredClone(value); + } + + /** Drop entries for files a listing no longer found, so deletes do not pile up. */ + retain(filePaths: Set): void { + for (const key of this.entries.keys()) { + if (!filePaths.has(key)) this.entries.delete(key); + } + } +} diff --git a/lib/admin/proofing-view.ts b/lib/admin/proofing-view.ts new file mode 100644 index 0000000..609f21e --- /dev/null +++ b/lib/admin/proofing-view.ts @@ -0,0 +1,96 @@ +/** + * What the admin panel sees of a proofing session: the stored record plus the + * album's name and the state, without the Immich lookups repeated per caller. + */ + +import { immich, type ImmichAlbum } from '@/lib/immich'; +import { imageUrl } from '@/lib/urls'; +import { + downloadsRemaining, + sessionState, + type ProofSession, + type ProofState, +} from '@/lib/proofSessions'; + +export interface AdminProofSession { + id: string; + clientName: string; + albumId: string; + /** Null when Immich no longer has the album (or cannot be reached). */ + albumName: string | null; + albumAssetCount: number | null; + /** Relative path of the client link. */ + link: string; + createdAt: string; + expiresOn: string | null; + download: ProofSession['download']; + downloadLimit: number | null; + downloadsUsed: number; + downloadsRemaining: number | null; + selected: number; + updatedAt: string | null; + submittedAt: string | null; + state: ProofState; +} + +export interface AdminProofPick { + assetId: string; + position: number; + fileName: string; + thumbUrl: string; + takenAt?: string; +} + +/** Load each album once, tolerating ones that are gone or unreachable. */ +export async function loadAlbums(ids: string[]): Promise> { + const unique = Array.from(new Set(ids)); + const albums = await Promise.all( + unique.map((id) => immich.getProofingAlbum(id).catch(() => null)), + ); + return new Map(unique.map((id, i) => [id, albums[i]])); +} + +export function toAdminSession( + session: ProofSession, + album: ImmichAlbum | null, +): AdminProofSession { + return { + id: session.id, + clientName: session.clientName, + albumId: session.albumId, + albumName: album?.albumName ?? null, + albumAssetCount: album ? album.assets.length : null, + link: `/proof/${session.token}`, + createdAt: session.createdAt, + expiresOn: session.expiresOn ?? null, + download: session.download, + downloadLimit: session.downloadLimit ?? null, + downloadsUsed: session.downloadsUsed, + downloadsRemaining: downloadsRemaining(session), + selected: session.selection.length, + updatedAt: session.updatedAt ?? null, + submittedAt: session.submittedAt ?? null, + state: sessionState(session), + }; +} + +/** The selected photos in album order, with what the export and preview need. */ +export function picksFor(session: ProofSession, album: ImmichAlbum | null): AdminProofPick[] { + if (!album) return []; + const chosen = new Set(session.selection); + const media = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO'); + const picks: AdminProofPick[] = []; + media.forEach((asset, index) => { + if (!chosen.has(asset.id)) return; + picks.push({ + assetId: asset.id, + position: index + 1, + fileName: asset.originalFileName, + thumbUrl: imageUrl(asset.id, 'thumbnail'), + ...(asset.localDateTime || asset.fileCreatedAt + ? { takenAt: asset.localDateTime ?? asset.fileCreatedAt } + : {}), + }); + }); + return picks; +} diff --git a/lib/admin/yaml-service.ts b/lib/admin/yaml-service.ts index 271f0e4..276a96e 100644 --- a/lib/admin/yaml-service.ts +++ b/lib/admin/yaml-service.ts @@ -8,35 +8,67 @@ import path from 'path'; import yaml from 'js-yaml'; import { atomicWrite } from '../atomicWrite'; import type { GalleryYaml, SettingsYaml } from '../config/schema'; +import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion'; const CONTENT_DIR = path.join(process.cwd(), 'content'); const MAX_BACKUPS = 10; // Keep last 10 backups per file -/** Read gallery.yaml and return parsed content. */ -export async function readGalleryYaml(): Promise { +/** A parsed YAML file and the version (#601) of the bytes it was parsed from. */ +export interface VersionedYaml { + data: T | null; + version: string; +} + +async function readYamlVersioned(filename: string): Promise> { try { - const raw = await fs.readFile(path.join(CONTENT_DIR, 'gallery.yaml'), 'utf8'); - return yaml.load(raw) as GalleryYaml; + const { text, version } = await readVersioned(path.join(CONTENT_DIR, filename)); + return { data: yaml.load(text) as T, version }; } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { data: null, version: versionOf(null) }; + } throw err; } } +/** Read gallery.yaml and return parsed content. */ +export async function readGalleryYaml(): Promise { + return (await readGalleryYamlVersioned()).data; +} + +/** gallery.yaml with its version, for the editor that will save it back. */ +export function readGalleryYamlVersioned(): Promise> { + return readYamlVersioned('gallery.yaml'); +} + /** Read settings.yaml and return parsed content. */ export async function readSettingsYaml(): Promise { - try { - const raw = await fs.readFile(path.join(CONTENT_DIR, 'settings.yaml'), 'utf8'); - return yaml.load(raw) as SettingsYaml; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw err; - } + return (await readSettingsYamlVersioned()).data; } -/** Atomically write a YAML file with backup. */ -async function writeYamlFile(filename: string, data: unknown): Promise { +/** settings.yaml with its version, for the editor that will save it back. */ +export function readSettingsYamlVersioned(): Promise> { + return readYamlVersioned('settings.yaml'); +} + +/** + * Atomically write a YAML file with backup, returning the new version. + * + * With a `baseVersion`, the write is refused with a VersionConflictError when + * the file on disk is no longer that version (#601) — checked inside the write + * queue, so no other save can slip in between the check and the rename. + */ +function writeYamlFile(filename: string, data: unknown, baseVersion?: string): Promise { + return serializeContentWrite(() => writeYamlFileNow(filename, data, baseVersion)); +} + +async function writeYamlFileNow( + filename: string, + data: unknown, + baseVersion: string | undefined, +): Promise { const filePath = path.join(CONTENT_DIR, filename); + await assertVersion(filePath, baseVersion); // Ensure content directory exists await fs.mkdir(CONTENT_DIR, { recursive: true }); @@ -85,16 +117,17 @@ async function writeYamlFile(filename: string, data: unknown): Promise { await atomicWrite(filePath, content); console.log(`[Admin] ✅ Saved ${filename}`); + return versionOf(content); } -/** Write gallery.yaml. */ -export async function writeGalleryYaml(data: GalleryYaml): Promise { - await writeYamlFile('gallery.yaml', data); +/** Write gallery.yaml; see writeYamlFile for `baseVersion`. Returns the new version. */ +export function writeGalleryYaml(data: GalleryYaml, baseVersion?: string): Promise { + return writeYamlFile('gallery.yaml', data, baseVersion); } -/** Write settings.yaml. */ -export async function writeSettingsYaml(data: SettingsYaml): Promise { - await writeYamlFile('settings.yaml', data); +/** Write settings.yaml; see writeYamlFile for `baseVersion`. Returns the new version. */ +export function writeSettingsYaml(data: SettingsYaml, baseVersion?: string): Promise { + return writeYamlFile('settings.yaml', data, baseVersion); } /** List available backups for a file. */ diff --git a/lib/auth.ts b/lib/auth.ts index fc36afd..17701b9 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,5 +1,6 @@ /** - * Password gating for subpages, albums, journal entries and the site as a whole. + * Password gating for subpages, albums, journal entries, content pages and the + * site as a whole. * Uses HMAC tokens stored in HttpOnly cookies — no database needed. * * Token = HMAC-SHA256(slug + passwordSecret, authSecret) @@ -11,12 +12,12 @@ import { getConfig, SubpageConfig } from './config'; // From the pure schema module, not the config barrel: tests that stub out // '@/lib/config' wholesale would otherwise lose this helper. import { normalizeSlug } from './config/schema'; -import { verifyScrypt, generateScryptHash, isScryptHash } from './password'; +import { verifyScrypt, generateScryptHash, isScryptHash, burnScrypt } from './password'; const TOKEN_EXPIRY_HOURS = 24; /** What a password can protect. */ -export type ProtectedType = 'subpage' | 'album' | 'journal' | 'site'; +export type ProtectedType = 'subpage' | 'album' | 'journal' | 'page' | 'site'; /** * Key used for the site-wide gate. There is only ever one, so it is a constant @@ -39,15 +40,24 @@ export const SITE_AUTH_KEY = 'site'; */ const SITE_TOKEN_KEY = '__site__'; -/** The key that actually gets signed: only the site gate is remapped. */ +/** + * The key that actually gets signed. The site gate is remapped (see above), and + * a content page is prefixed: a page and a subpage or journal entry of the same + * slug and password would otherwise sign identical tokens, and a visitor could + * copy one cookie's value into the other's name. `page:` cannot be a slug — + * isValidSlug() rejects the colon. + */ function tokenKey(key: string, type: ProtectedType): string { - return type === 'site' ? SITE_TOKEN_KEY : key; + if (type === 'site') return SITE_TOKEN_KEY; + if (type === 'page') return `page:${key}`; + return key; } const TYPE_LABELS: Record = { subpage: 'Subpage', album: 'Album', journal: 'Journal entry', + page: 'Page', site: 'Site', }; @@ -78,10 +88,12 @@ function authToken(key: string, passwordSecret: string, expiresAt: number): stri import nodeFs from 'fs'; import { parseFrontmatter } from './journal'; import { resolveJournalFilePath } from './admin/journal-service'; +import { readPageSync } from './admin/pages-service'; function cookieName(key: string, type: ProtectedType): string { if (type === 'subpage') return `lb_auth_${key}`; if (type === 'journal') return `lb_auth_journal_${key}`; + if (type === 'page') return `lb_auth_page_${key}`; if (type === 'site') return SITE_AUTH_COOKIE; return `lb_auth_album_${key}`; } @@ -107,7 +119,9 @@ export const SITE_AUTH_COOKIE = 'lb_site_auth'; * * An album listed on the home page is published there whatever else lists it, * so it stays reachable. Where the only routes are subpages, one of them has - * to be open — which mirrors what the visitor could actually have clicked. + * to be open — which mirrors what the visitor could actually have clicked. A + * subpage taken offline with `enabled: false` is no route at all, so an album + * listed only there is not reachable, whatever its password. */ export function isAlbumReachable( albumId: string, @@ -121,7 +135,7 @@ export function isAlbumReachable( const routes = config.subpages.filter( (sp) => sp.enabled !== false && sp.albumIds.includes(albumId), ); - if (routes.length === 0) return true; + if (routes.length === 0) return false; return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage')); } @@ -175,6 +189,9 @@ function findPassword(key: string, type: ProtectedType): string | undefined { } catch {} return undefined; } + if (type === 'page') { + return readPageSync(key)?.parsed.frontmatter.password || undefined; + } return undefined; } @@ -217,8 +234,13 @@ export async function authenticate( type: ProtectedType = 'subpage', secure = true, ): Promise { + // Every path below runs exactly one scrypt, so the reply time does not tell a + // prober which keys exist, which carry a password, or how it is stored. const storedPassword = findPassword(key, type); - if (!storedPassword) return null; + if (!storedPassword) { + await burnScrypt(password); + return null; + } let isValid = false; @@ -229,6 +251,7 @@ export async function authenticate( ` Please switch temporarily to plaintext in your gallery.yaml, log in again\n` + ` to see your new secure "scrypt:..." hash in the logs, and update your file.\n`, ); + await burnScrypt(password); return null; } @@ -247,7 +270,10 @@ export async function authenticate( .digest(); isValid = crypto.timingSafeEqual(attemptHash, storedHash); - if (isValid) { + if (!isValid) { + await burnScrypt(password); + } else { + // The success path pays its scrypt here, for the recommended hash. const recommendedHash = await generateScryptHash(storedPassword); console.warn( `\n⚠️ SECURITY WARNING: ${TYPE_LABELS[type]} "${key}" is using a plaintext password${type === 'site' ? ' in settings.yaml' : ' in gallery.yaml'}.\n` + diff --git a/lib/cache.ts b/lib/cache.ts index e6c0770..b93317b 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -17,8 +17,28 @@ interface CacheEntry { const DEFAULT_STALE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours +/** + * The entries live on globalThis, not in this module. + * + * Next bundles each route separately, so every route handler gets its own + * instance of this module. A module-level Map meant the admin panel's + * "Clear cache", a save's invalidation and the Immich webhook each cleared a + * cache no page ever read from, and the diagnostics page reported the size + * of its own, always empty, copy ("0 items"). The process is shared, so one + * store on globalThis is the cache every instance reads and clears. The key is + * versioned so a dev server's hot reload never hands a new build entries in + * an older shape. + */ +const STORE_KEY = Symbol.for('immich-folio.immich-cache.v2'); + +function sharedStore(): Map> { + const holder = globalThis as unknown as Record>>; + holder[STORE_KEY] ??= new Map(); + return holder[STORE_KEY]; +} + class MemoryCache { - private store = new Map>(); + private store = sharedStore(); private maxEntries = 200; get size(): number { diff --git a/lib/config/index.ts b/lib/config/index.ts index 81c9fb2..d1fe25e 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { env } from '../env'; import { resolveAuthSecret } from '../secret'; import { resolveSiteUrl } from '../siteUrl'; -import { getInstallCredentials } from '../install'; +import { getInstallCredentials, installFilePath } from '../install'; import { loadYaml, clearYamlCache, validateUuid } from './parser'; import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme'; import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort'; @@ -30,14 +30,23 @@ import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl, + isPageRef, + type GallerySubpageYaml, } from './schema'; +import { isValidSlug } from '../journal'; +import { getDictionary, resolveLocale } from '../i18n'; +import type { NavEntry } from '../pages'; export * from './schema'; export * from './theme'; +/** The last derived config and the inputs it was derived from; see getConfig(). */ +let derived: { inputs: string; config: AppConfig } | null = null; + /** Invalidate the cached YAML so the next getConfig() call re-parses the files. */ export function invalidateConfigCache(): void { clearYamlCache(); + derived = null; } /** @@ -150,6 +159,20 @@ export function resolveProofing( return subpage?.proofing ?? globalEnabled; } +/** + * The recipient of the proofing dialog's "email to photographer" (#736): + * `proofing.email` when set, else the footer contact email. The first + * non-empty value, trimmed — an address saved as only whitespace is not + * configured. Undefined means neither is set, and the dialog hides the button + * rather than opening a compose with an empty To:. + */ +export function resolveProofingEmail( + proofingEmail?: string, + footerEmail?: string, +): string | undefined { + return (proofingEmail ?? '').trim() || (footerEmail ?? '').trim() || undefined; +} + /** * EXPERIMENTAL: keep only nav links that are safe to render as header * tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are @@ -244,6 +267,7 @@ export interface GalleryDerivation { albums: string[]; standaloneAlbums: string[]; subpages: SubpageConfig[]; + nav: NavEntry[]; albumOverrides: Record; albumDescriptions: Record; albumPasswords: Record; @@ -408,9 +432,35 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums'); let subpages: SubpageConfig[] = []; + /** Menu order, subpages and `- page:` references interleaved (#722). */ + const nav: NavEntry[] = []; if (Array.isArray(gallery.subpages)) { - subpages = gallery.subpages.map((sp) => { + const pageRefs = new Set(); + const subpageEntries: GallerySubpageYaml[] = []; + for (const entry of gallery.subpages) { + if (isPageRef(entry)) { + const slug = entry.page.trim(); + // Thrown like every other structure error: the admin PUT turns it into + // a 400, and a hand-edited file degrades to the setup screen. + if (!isValidSlug(slug)) { + throw new Error( + `gallery.yaml subpages: "page: ${entry.page}" is not a valid page slug ` + + `(letters, digits, "-" and "_" only).`, + ); + } + if (pageRefs.has(slug)) { + throw new Error(`gallery.yaml subpages: page "${slug}" is listed more than once.`); + } + pageRefs.add(slug); + nav.push({ type: 'page', slug }); + } else { + subpageEntries.push(entry); + // Placeholder, filled in with the derived slug below. + nav.push({ type: 'subpage', slug: '' }); + } + } + subpages = subpageEntries.map((sp) => { if (!sp.name) { throw new Error(`Subpage "(unnamed)" must have a name`); } @@ -467,6 +517,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { ...buildSubpageGrids(sp.grid, sp.coverGrid), }; }); + let next = 0; + for (const entry of nav) { + if (entry.type === 'subpage') entry.slug = subpages[next++].slug; + } } else if (gallery.subpages) { subpages = Object.entries(gallery.subpages).map(([name, value]) => { if (Array.isArray(value)) { @@ -500,6 +554,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { }); } + if (!Array.isArray(gallery.subpages)) { + for (const sp of subpages) nav.push({ type: 'subpage', slug: sp.slug }); + } + const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds)); const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])]; const standaloneAlbums = standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)); @@ -550,6 +608,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { albums: allAlbumIds, standaloneAlbums, subpages, + nav, albumOverrides, albumDescriptions, albumPasswords, @@ -572,11 +631,54 @@ function aboutContentExists(): boolean { } } +/** + * Everything deriveConfig() reads, as one string: the path and mtime of each + * file (or `-` when it is absent) and the parsed environment. + */ +function configInputs(): string { + const content = path.join(process.cwd(), 'content'); + const files = [ + installFilePath(), + path.join(content, 'gallery.yaml'), + path.join(content, 'settings.yaml'), + path.join(content, 'about.md'), + ]; + // throwIfNoEntry: a missing file is the normal case for install.json and + // about.md, and building an ENOENT error costs more than the stat. + // turbopackIgnore: `file` is not statically resolvable, so without the + // comment Turbopack traces the whole project into .next/standalone. Only + // the mtime is read here; the files themselves are loaded elsewhere. + const stamps = files.map( + (file) => + `${file}@${fs.statSync(/* turbopackIgnore: true */ file, { throwIfNoEntry: false })?.mtimeMs ?? '-'}`, + ); + return `${stamps.join('|')}|${JSON.stringify(env)}`; +} + +/** + * The site configuration. + * + * Admin saves can land in a different worker or process than page rendering, + * so this cannot be a per-process singleton: freshness comes from the files' + * mtimes, re-checked on every call (a statSync each). What is cached is the + * derivation. A render and every image request call this many times over + * (`immich.config` is a getter), and rebuilding the whole config from the + * YAML each time cost more than the stats. + * + * The inputs are the ones loadYaml() and readInstallFile() already key their + * own caches on, so this is never staler than they are. Each caller still + * gets its own copy, exactly as before: nothing can mutate the cached one. + * A config that throws is not cached, so it throws again on the next call. + */ export function getConfig(): AppConfig { - // No in-memory config cache: admin saves can land in a different - // worker/process than page rendering, so a per-worker cache goes stale - // until restart. Freshness comes from the mtime-checked YAML cache in - // loadYaml() — a statSync per file, negligible next to Immich API calls. + const inputs = configInputs(); + if (derived?.inputs !== inputs) { + derived = { inputs, config: deriveConfig() }; + } + return structuredClone(derived.config); +} + +function deriveConfig(): AppConfig { const { apiUrl, apiKey } = getInstallCredentials(); const authSecret = resolveAuthSecret(); @@ -604,6 +706,7 @@ export function getConfig(): AppConfig { albums: [], standaloneAlbums: [], subpages: [], + nav: [], siteTitle: env.SITE_TITLE || 'Immich Folio', siteSubtitle: env.SITE_SUBTITLE || 'Setup Required', lang: 'en', @@ -665,6 +768,7 @@ export function getConfig(): AppConfig { albums: allAlbumIds, standaloneAlbums, subpages, + nav, albumOverrides, albumDescriptions, albumPasswords, @@ -677,7 +781,10 @@ export function getConfig(): AppConfig { albumLocationPrecision, } = deriveGallery(gallery); - const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery'; + // Last-resort title in the site's own language rather than a hardcoded + // English "Gallery" (#696). + const defaultTitle = env.SITE_TITLE || getDictionary(resolveLocale(settings.lang)).common.gallery; + const siteSeoTitle = settings.seo?.title || settings.title || defaultTitle; return { immich: { apiUrl: immichApiUrl, apiKey }, @@ -685,7 +792,8 @@ export function getConfig(): AppConfig { albums: allAlbumIds, standaloneAlbums, subpages, - siteTitle: settings.title ?? env.SITE_TITLE, + nav, + siteTitle: settings.title ?? defaultTitle, siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, lang: settings.lang ?? 'en', // Env wins, so a deployment can rotate the site password without touching @@ -746,6 +854,9 @@ export function getConfig(): AppConfig { proofing: { enabled: settings.proofing?.enabled !== false, allowMailto: settings.proofing?.allowMailto !== false, + // `proofing.email`, else the footer contact email (#736); absent hides + // the dialog's email button. + email: resolveProofingEmail(settings.proofing?.email, settings.footer?.email), }, protection: settings.protection, watermark: settings.watermark, diff --git a/lib/config/schema.ts b/lib/config/schema.ts index ad1a244..293629f 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -1,5 +1,6 @@ import type { AlbumSortMode } from '../albumSort'; import type { LocationPrecision } from '../mapPrecision'; +import type { NavEntry } from '../pages'; export interface SubpageSectionConfig { title: string; @@ -217,6 +218,11 @@ export interface AppConfig { albums: string[]; standaloneAlbums: string[]; subpages: SubpageConfig[]; + /** + * The header menu in gallery.yaml order: every subpage (hidden and disabled + * ones included — the menu filters) and every `- page:` reference (#722). + */ + nav: NavEntry[]; siteTitle: string; siteSubtitle: string; lang: string; @@ -264,6 +270,13 @@ export interface AppConfig { proofing: { enabled: boolean; allowMailto: boolean; + /** + * Effective "email to photographer" recipient: `proofing.email` from + * settings when set, otherwise the footer contact email. Undefined means + * neither is configured, and the proofing dialog hides the email action + * rather than opening a compose with an empty To: (#736). + */ + email?: string; }; aboutEnabled: boolean; albumOverrides: Record; @@ -352,43 +365,63 @@ export interface AlbumEntryObject { location?: string; } +/** + * A content page placed in the menu (#722): `- page: ` among the + * subpages. The page itself lives in content/pages/.md. + */ +export interface PageRefYaml { + page: string; +} + +/** Whether a raw `subpages:` list entry is a page reference rather than a subpage. */ +export function isPageRef(entry: unknown): entry is PageRefYaml { + return ( + typeof entry === 'object' && + entry !== null && + 'page' in entry && + typeof (entry as { page: unknown }).page === 'string' + ); +} + export interface GalleryYaml { hero?: string | string[]; albums?: Array>; subpages?: | Record>> - | Array<{ - name: string; - title?: string; - subtitle?: string; - albums?: Array>; - sections?: Array<{ - title: string; - description?: string; - albums: Array>; - }>; - password?: string; - proofing?: boolean; - essayFile?: string; - essayText?: string; - enabled?: boolean; - hidden?: boolean; - /** Map precision inherited by every album here (#469). */ - location?: string; - grid?: { - columns?: number; - gap?: number; - aspectRatio?: string; - layout?: string; - }; - /** Album covers only; falls back to `grid` when unset (#523). */ - coverGrid?: { - columns?: number; - gap?: number; - aspectRatio?: string; - layout?: string; - }; - }>; + | Array; +} + +export interface GallerySubpageYaml { + name: string; + title?: string; + subtitle?: string; + albums?: Array>; + sections?: Array<{ + title: string; + description?: string; + albums: Array>; + }>; + password?: string; + proofing?: boolean; + essayFile?: string; + essayText?: string; + enabled?: boolean; + hidden?: boolean; + /** Map precision inherited by every album here (#469). */ + location?: string; + grid?: { + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; + }; + /** Album covers only; falls back to `grid` when unset (#523). */ + coverGrid?: { + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; + }; } export interface SettingsYaml { @@ -417,6 +450,8 @@ export interface SettingsYaml { proofing?: { enabled?: boolean; allowMailto?: boolean; + /** Overrides the footer contact email as the proofing recipient. */ + email?: string; }; theme?: | string @@ -644,3 +679,44 @@ export function normalizeSlug(slug: string): string { } return decoded.normalize('NFC'); } + +/** + * The albums the public site shows: the standalone ones and those of every + * subpage that is not offline. `albums` is the allowlist for loading an album + * and still holds the albums of a subpage taken offline with `enabled: false` + * (its configuration is kept), so it does not answer "is this published". + * Anything that lists albums or serves their photos outside a page route asks + * this instead. + */ +export function onlineAlbumIds(config: { + standaloneAlbums?: readonly string[]; + subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean }>; +}): Set { + return new Set([ + ...(config.standaloneAlbums ?? []), + ...config.subpages.filter((sp) => sp.enabled !== false).flatMap((sp) => sp.albumIds), + ]); +} + +/** A subpage that appears in listings: online and not `hidden`. */ +export function isListedSubpage(sp: { enabled?: boolean; hidden?: boolean }): boolean { + return sp.enabled !== false && sp.hidden !== true; +} + +/** + * The albums a public listing may name: the standalone ones and those of + * every subpage that is online and not `hidden`. Narrower than + * {@link onlineAlbumIds} — a hidden subpage is reachable by direct link only, + * so its photos are still served (publishedAssets asks `onlineAlbumIds`), but + * an overview such as the map must not advertise them. An album that a listed + * subpage or the standalone list also carries stays. + */ +export function listedAlbumIds(config: { + standaloneAlbums?: readonly string[]; + subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean; hidden?: boolean }>; +}): Set { + return new Set([ + ...(config.standaloneAlbums ?? []), + ...config.subpages.filter(isListedSubpage).flatMap((sp) => sp.albumIds), + ]); +} diff --git a/lib/config/settingValues.ts b/lib/config/settingValues.ts new file mode 100644 index 0000000..ab7264f --- /dev/null +++ b/lib/config/settingValues.ts @@ -0,0 +1,71 @@ +/** + * Values in settings.yaml that the site would silently ignore (QA A-14). + * + * `validateSettings` (settingsSchema.ts) checks types and structure and + * deliberately leaves values to the resolvers, which clamp or fall back so + * hand-edited YAML never takes the site down. For the fields the panel offers + * an input for, that meant a save of `accent: rot` or nine columns in a 1–6 + * field was reported as "Saved" while the site showed something else. + * + * This checks exactly those fields — the site URL, the accent, the grid + * columns and gap — with the resolvers' own rules and bounds, so it can never + * be stricter than what renders. Kept apart from settingsSchema.ts so the + * settings form can run it as you type without bundling zod; the save route + * runs it before writing, and the doctor over settings.yaml as it is. + */ + +import { normaliseSiteUrl } from '../siteUrl'; +import { isAccentHex } from './theme'; +import { PHOTO_GRID_COLUMNS_MAX, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_GAP_MAX } from './schema'; +import type { SettingsFieldError } from './settingsSchema'; + +function inRange(value: unknown, min: number, max: number): boolean { + return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max; +} + +/** One error per offending field; an empty string counts as unset, as in the resolvers. */ +export function validateSettingValues(input: unknown): SettingsFieldError[] { + if (!input || typeof input !== 'object') return []; + const s = input as { + url?: unknown; + theme?: unknown; + grid?: { columns?: unknown; gap?: unknown } | null; + }; + const errors: SettingsFieldError[] = []; + + // resolveSiteUrl: an unusable value falls back to SITE_URL, or to nothing, + // and the sitemap, feed and structured data go out without the address. + if (typeof s.url === 'string' && s.url.trim() !== '' && !normaliseSiteUrl(s.url)) { + errors.push({ + field: 'url', + message: 'Must be a full http(s) address, e.g. https://photos.example.com.', + }); + } + + // resolveTheme: anything but hex falls back to the preset's accent. + const accent = + s.theme && typeof s.theme === 'object' ? (s.theme as { accent?: unknown }).accent : undefined; + if (typeof accent === 'string' && accent.trim() !== '' && !isAccentHex(accent)) { + errors.push({ field: 'theme.accent', message: 'Must be a hex colour such as #e60012.' }); + } + + // getConfig clamps both into these bounds (lib/config/index.ts). + const grid = s.grid && typeof s.grid === 'object' ? s.grid : null; + if ( + grid?.columns != null && + !inRange(grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX) + ) { + errors.push({ + field: 'grid.columns', + message: `Must be a whole number from ${PHOTO_GRID_COLUMNS_MIN} to ${PHOTO_GRID_COLUMNS_MAX}.`, + }); + } + if (grid?.gap != null && !inRange(grid.gap, 0, PHOTO_GRID_GAP_MAX)) { + errors.push({ + field: 'grid.gap', + message: `Must be a whole number from 0 to ${PHOTO_GRID_GAP_MAX}.`, + }); + } + + return errors; +} diff --git a/lib/config/settingsSchema.ts b/lib/config/settingsSchema.ts index ca8fe1a..ccb346e 100644 --- a/lib/config/settingsSchema.ts +++ b/lib/config/settingsSchema.ts @@ -29,6 +29,10 @@ * to the preset's own value for anything that is not the right type, which * covers hand-edited YAML as well as a save from this panel. * + * **Values the panel offers an input for** are the exception, checked with the + * resolvers' own rules in `settingValues.ts`: the site URL, the accent and the + * grid columns and gap, which a save used to accept and the site then ignored. + * * **Unknown keys pass through.** There is no `schemaVersion` and no migration * code, so configurations older and newer than this build are both in the wild. * A key this version has never heard of is not evidence of a mistake, and @@ -84,7 +88,7 @@ export const settingsSchema = z.looseObject({ scrollToTop: bool, analytics: bool, - proofing: z.looseObject({ enabled: bool, allowMailto: bool }).optional(), + proofing: z.looseObject({ enabled: bool, allowMailto: bool, email: str }).optional(), // Checked separately in validateSettings — see themeObjectSchema. theme: z.unknown().optional(), diff --git a/lib/config/theme.ts b/lib/config/theme.ts index f5ea47f..bd0cc6f 100644 --- a/lib/config/theme.ts +++ b/lib/config/theme.ts @@ -130,6 +130,11 @@ function resolveRadius(raw: unknown, fallback: number): number { */ const ACCENT_HEX = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; +/** Whether `resolveTheme` keeps an accent rather than falling back to the preset's. */ +export function isAccentHex(value: string): boolean { + return ACCENT_HEX.test(value.trim()); +} + /** Trim, case-fold and widen to `#rrggbb`; refuse anything that is not hex. */ function resolveAccent(raw: unknown, fallback: string): string { if (typeof raw !== 'string') return fallback; diff --git a/lib/env.ts b/lib/env.ts index 57e5e2f..40a3056 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -152,7 +152,8 @@ function parseEnv(): Env { return { IMMICH_API_URL: apiUrl, IMMICH_API_KEY: apiKey as string, - SITE_TITLE: process.env.SITE_TITLE || 'Gallery', + // No English default here: lib/config falls back to the locale's word. + SITE_TITLE: process.env.SITE_TITLE || '', SITE_SUBTITLE: process.env.SITE_SUBTITLE || '', CACHE_TTL: Math.max(0, cacheTtl), STALE_MAX_AGE: Math.max(0, staleMaxAge), diff --git a/lib/escapeHtml.ts b/lib/escapeHtml.ts new file mode 100644 index 0000000..80a5fc9 --- /dev/null +++ b/lib/escapeHtml.ts @@ -0,0 +1,16 @@ +/** + * Escapes special HTML characters for Leaflet's string-based popups and icons. + * + * Lives outside components/LeafletMap.tsx so a caller can build marker HTML + * without importing the map — EssayView loads the map on demand, and a static + * import of this helper from there would pull it (and Leaflet's stylesheet) + * back into every essay page. + */ +export function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/lib/essaySource.ts b/lib/essaySource.ts index 7923983..c382585 100644 --- a/lib/essaySource.ts +++ b/lib/essaySource.ts @@ -18,8 +18,11 @@ export type EssayFileResult = | { status: 'open'; parsed: ParsedJournal } /** No such entry, or a draft seen by a non-admin — both render as absent. */ | { status: 'missing' } - /** The entry has a password this visitor has not entered. */ - | { status: 'locked'; title: string }; + /** + * The entry has a password this visitor has not entered. No title: the + * journal index hides a locked entry by name, so the gate must not show it. + */ + | { status: 'locked' }; export function resolveEssayFile( slug: string, @@ -31,7 +34,7 @@ export function resolveEssayFile( const { frontmatter } = parsed; if (frontmatter.draft && !visitor.isAdmin) return { status: 'missing' }; if (frontmatter.password && !isAuthenticated(slug, visitor.getCookie, 'journal')) { - return { status: 'locked', title: frontmatter.title || slug }; + return { status: 'locked' }; } return { status: 'open', parsed }; } diff --git a/lib/focusTrap.ts b/lib/focusTrap.ts new file mode 100644 index 0000000..6f75ed3 --- /dev/null +++ b/lib/focusTrap.ts @@ -0,0 +1,55 @@ +/** + * Focus helpers for modal UI (lightbox) and the mobile nav panel (#696). + * Client-only: every function works on live DOM nodes. + */ + +const FOCUSABLE = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +/** Keyboard-reachable elements inside `container`, in DOM order. */ +export function focusableIn(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE)).filter( + (el) => + !el.closest('[hidden], [inert], [aria-hidden="true"]') && + el.tabIndex >= 0 && + // A `display: none` control (the shortcut panel on touch devices) cannot + // take focus; wrapping onto it would strand the visitor. + (typeof el.checkVisibility !== 'function' || el.checkVisibility()), + ); +} + +/** + * Keeps Tab and Shift+Tab inside `container`: from the last element Tab wraps + * to the first, and the other way round. Focus that has escaped (or never + * entered) is pulled back in. Returns true when it moved focus itself. + */ +export function trapTabKey(container: HTMLElement, e: KeyboardEvent): boolean { + if (e.key !== 'Tab') return false; + const items = focusableIn(container); + if (items.length === 0) { + e.preventDefault(); + return true; + } + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement as HTMLElement | null; + const inside = active !== null && container.contains(active); + + if (e.shiftKey && (!inside || active === first)) { + e.preventDefault(); + last.focus(); + return true; + } + if (!e.shiftKey && (!inside || active === last)) { + e.preventDefault(); + first.focus(); + return true; + } + return false; +} diff --git a/lib/i18n/locales/de.ts b/lib/i18n/locales/de.ts index 6b7afa8..07f8987 100644 --- a/lib/i18n/locales/de.ts +++ b/lib/i18n/locales/de.ts @@ -19,6 +19,8 @@ export const de: Dictionary = { map: 'Karte', journal: 'Journal', skipToContent: 'Zum Inhalt springen', + mainNavAria: 'Hauptnavigation', + heroNavAria: 'Bereiche', openMenu: 'Menü öffnen', closeMenu: 'Menü schließen', }, @@ -44,12 +46,15 @@ export const de: Dictionary = { nextEntryAria: (title: string) => `Nächster Beitrag: ${title}`, entryNavAria: 'Journal-Navigation', loadingGallery: 'Galerie wird geladen', + gallery: 'Galerie', loadingPhotos: 'Fotos werden geladen', downloadAlbum: 'Album herunterladen', }, home: { enter: 'Eintreten', + pauseSlideshow: 'Diashow anhalten', + playSlideshow: 'Diashow abspielen', }, error: { @@ -58,8 +63,6 @@ export const de: Dictionary = { errorTitle: 'Etwas ist schiefgelaufen', errorText: 'Diese Seite konnte gerade nicht geladen werden. Das ist meist vorübergehend — bitte gleich noch einmal versuchen.', - siteErrorText: - 'Diese Website konnte gerade nicht geladen werden. Das ist meist vorübergehend — bitte gleich noch einmal versuchen.', tryAgain: 'Erneut versuchen', reference: (digest: string) => `Referenz: ${digest}`, }, @@ -71,6 +74,7 @@ export const de: Dictionary = { immichUnavailable: 'Die Fotobibliothek ist gerade nicht erreichbar. Bitte gleich noch einmal versuchen.', back: 'Zurück zur Galerie', + limitReached: 'Das Download-Limit für diesen Link ist erreicht.', }, theme: { @@ -82,11 +86,18 @@ export const de: Dictionary = { password: { subtitle: 'Diese Galerie ist passwortgeschützt.', siteSubtitle: 'Diese Website ist passwortgeschützt.', + pageSubtitle: 'Diese Seite ist passwortgeschützt.', + journalSubtitle: 'Dieser Journal-Eintrag ist passwortgeschützt.', + protectedPage: 'Geschützte Seite', placeholder: 'Passwort eingeben', submit: 'Öffnen', verifying: 'Wird geprüft…', incorrect: 'Falsches Passwort. Bitte erneut versuchen.', failed: 'Passwort konnte nicht geprüft werden. Bitte später erneut versuchen.', + tooManyAttempts: (seconds: number | null) => + seconds + ? `Zu viele Versuche – bitte in ${seconds} ${seconds === 1 ? 'Sekunde' : 'Sekunden'} erneut versuchen.` + : 'Zu viele Versuche – bitte später erneut versuchen.', }, about: { @@ -104,6 +115,9 @@ export const de: Dictionary = { loading: 'Karte wird geladen…', loadFailed: (status: number) => `Kartendaten konnten nicht geladen werden (${status})`, initFailed: 'Karte konnte nicht initialisiert werden', + zoomIn: 'Vergrößern', + zoomOut: 'Verkleinern', + leafletTitle: 'Eine JavaScript-Bibliothek für interaktive Karten', }, subpage: { @@ -130,6 +144,7 @@ export const de: Dictionary = { lightbox: { viewer: 'Bildansicht', + position: (n: number, total: number) => `Foto ${n} von ${total}`, openPhoto: (n: number) => `Foto ${n} ansehen`, close: 'Schließen', closeTitle: 'Schließen (Esc)', @@ -164,10 +179,10 @@ export const de: Dictionary = { downloadTitle: 'Originaldatei herunterladen (d)', downloadShort: 'Original', shortcutDownload: 'Original herunterladen', - shortcutSlideshow: 'Diaschau', - shortcutSlideshowRunning: (seconds: number) => `Diaschau — alle ${seconds}s`, - slideshowStopped: 'Diaschau angehalten', - slideshowRunning: (seconds: number) => `Diaschau läuft, wechselt alle ${seconds} Sekunden`, + shortcutSlideshow: 'Diashow', + shortcutSlideshowRunning: (seconds: number) => `Diashow — alle ${seconds}s`, + slideshowStopped: 'Diashow angehalten', + slideshowRunning: (seconds: number) => `Diashow läuft, wechselt alle ${seconds} Sekunden`, shortcutCopyLink: 'Link zum Foto kopieren', shortcutList: 'Diese Liste', shortcutClose: 'Ansicht schließen', @@ -181,6 +196,7 @@ export const de: Dictionary = { saved: 'Gemerkt', favorite: 'Merken', showAll: 'Alle anzeigen', + filterEmpty: 'Keine Fotos ausgewählt.', selected: (n: number) => `❤️ ${n} ausgewählt`, shareExport: 'Teilen & Export', modalTitle: (n: number) => `❤️ Auswahl (${n})`, @@ -200,11 +216,36 @@ export const de: Dictionary = { listEmpty: (albumName: string) => `${albumName}: Keine Fotos ausgewählt.`, listSummary: (albumName: string, count: number, indices: string) => `${albumName} — Ausgewählte Fotos (${count}): ${indices}`, - mailSubject: (n: number) => `Fotoauswahl (${n} Fotos)`, + mailSubject: (n: number) => `Fotoauswahl (${plural(n, 'Foto', 'Fotos')})`, mailBody: (list: string, url: string) => `Hallo,\n\nhier ist meine Fotoauswahl:\n\n${list}\n\nLink zur Auswahl: ${url}\n\nViele Grüße,`, }, + proofSession: { + greeting: (name: string) => `Auswahl für ${name}`, + intro: + 'Markiere die gewünschten Fotos mit dem Herz. Deine Auswahl wird laufend gespeichert — du kannst mit diesem Link jederzeit zurückkommen.', + saving: 'Wird gespeichert…', + saved: 'Auswahl gespeichert', + saveFailed: + 'Deine Auswahl konnte nicht gespeichert werden. Prüfe die Verbindung und versuche es erneut.', + review: 'Prüfen & absenden', + modalTitle: (n: number) => `Deine Auswahl (${n})`, + empty: 'Noch keine Fotos ausgewählt.', + submit: 'Auswahl absenden', + submitting: 'Wird gesendet…', + confirmSubmit: (n: number) => + `${plural(n, 'Foto', 'Fotos')} absenden? Danach lässt sich die Auswahl nicht mehr ändern.`, + submitted: 'Danke — deine Auswahl wurde abgeschickt.', + locked: 'Diese Auswahl wurde abgeschickt und kann nicht mehr geändert werden.', + validUntil: (date: string) => `Dieser Link ist gültig bis ${date}.`, + expiredTitle: 'Dieser Link ist abgelaufen', + expiredText: 'Bitte frag deinen Fotografen nach einem neuen Link.', + downloadSelection: 'Auswahl herunterladen (.zip)', + downloadAll: 'Alle Fotos herunterladen (.zip)', + downloadsLeft: (n: number) => `Noch ${plural(n, 'Download', 'Downloads')}`, + }, + legal: { navLabel: 'Impressum', title: 'Impressum', diff --git a/lib/i18n/locales/en.ts b/lib/i18n/locales/en.ts index 4dcacdb..d51f9c5 100644 --- a/lib/i18n/locales/en.ts +++ b/lib/i18n/locales/en.ts @@ -19,6 +19,8 @@ export const en = { map: 'Map', journal: 'Journal', skipToContent: 'Skip to content', + mainNavAria: 'Main', + heroNavAria: 'Sections', openMenu: 'Open menu', closeMenu: 'Close menu', }, @@ -45,12 +47,15 @@ export const en = { nextEntryAria: (title: string) => `Next entry: ${title}`, entryNavAria: 'Journal entry navigation', loadingGallery: 'Loading gallery', + gallery: 'Gallery', loadingPhotos: 'Loading photos', downloadAlbum: 'Download album', }, home: { enter: 'Enter', + pauseSlideshow: 'Pause slideshow', + playSlideshow: 'Play slideshow', }, error: { @@ -59,8 +64,6 @@ export const en = { errorTitle: 'Something went wrong', errorText: 'This page could not be loaded right now. It is usually temporary — try again in a moment.', - siteErrorText: - 'This site could not be loaded right now. It is usually temporary — try again in a moment.', tryAgain: 'Try again', reference: (digest: string) => `Reference: ${digest}`, }, @@ -72,6 +75,7 @@ export const en = { rateLimited: 'Too many download requests. Please wait a moment and try again.', immichUnavailable: 'The photo library is unavailable right now. Please try again shortly.', back: 'Back to the gallery', + limitReached: 'The download limit for this link has been reached.', }, theme: { @@ -83,11 +87,18 @@ export const en = { password: { subtitle: 'This gallery is password-protected.', siteSubtitle: 'This site is password-protected.', + pageSubtitle: 'This page is password-protected.', + journalSubtitle: 'This journal entry is password-protected.', + protectedPage: 'Protected page', placeholder: 'Enter password', submit: 'Enter', verifying: 'Verifying…', incorrect: 'Incorrect password. Please try again.', failed: 'Unable to verify password. Please try again later.', + tooManyAttempts: (seconds: number | null) => + seconds + ? `Too many attempts. Please try again in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}.` + : 'Too many attempts. Please try again later.', }, about: { @@ -105,6 +116,10 @@ export const en = { loading: 'Loading map…', loadFailed: (status: number) => `Failed to load map data (${status})`, initFailed: 'Failed to initialize map', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + // The title on the Leaflet credit in the map's corner. + leafletTitle: 'A JavaScript library for interactive maps', }, subpage: { @@ -131,6 +146,7 @@ export const en = { lightbox: { viewer: 'Image viewer', + position: (n: number, total: number) => `Photo ${n} of ${total}`, /** A grid tile, which opens the viewer on that photo. */ openPhoto: (n: number) => `View photo ${n}`, close: 'Close', @@ -183,6 +199,8 @@ export const en = { saved: 'Saved', favorite: 'Favorite', showAll: 'Show All', + /** Shown in place of the grid when the filter is on and nothing is selected. */ + filterEmpty: 'No photos selected.', selected: (n: number) => `❤️ ${n} Selected`, shareExport: 'Share & Export', modalTitle: (n: number) => `❤️ Selection (${n})`, @@ -203,11 +221,36 @@ export const en = { listEmpty: (albumName: string) => `${albumName}: No photos selected.`, listSummary: (albumName: string, count: number, indices: string) => `${albumName} — Selected Photos (${count}): ${indices}`, - mailSubject: (n: number) => `Photo Selection (${n} items)`, + mailSubject: (n: number) => `Photo Selection (${plural(n, 'item', 'items')})`, mailBody: (list: string, url: string) => `Hello,\n\nHere is my photo selection:\n\n${list}\n\nShare Link: ${url}\n\nBest regards,`, }, + /** Client proofing links (/proof/). */ + proofSession: { + greeting: (name: string) => `Selection for ${name}`, + intro: + 'Tap the heart on the photos you want. Your selection is saved as you go — you can come back with this link at any time.', + saving: 'Saving…', + saved: 'Selection saved', + saveFailed: 'Your selection could not be saved. Check your connection and try again.', + review: 'Review & submit', + modalTitle: (n: number) => `Your selection (${n})`, + empty: 'No photos selected yet.', + submit: 'Submit selection', + submitting: 'Submitting…', + confirmSubmit: (n: number) => + `Submit ${plural(n, 'photo', 'photos')}? The selection cannot be changed afterwards.`, + submitted: 'Thank you — your selection has been submitted.', + locked: 'This selection has been submitted and can no longer be changed.', + validUntil: (date: string) => `This link is valid until ${date}.`, + expiredTitle: 'This link has expired', + expiredText: 'Please ask your photographer for a new link.', + downloadSelection: 'Download selection (.zip)', + downloadAll: 'Download all photos (.zip)', + downloadsLeft: (n: number) => `${plural(n, 'download', 'downloads')} left`, + }, + legal: { navLabel: 'Legal Notice', title: 'Legal Notice', diff --git a/lib/i18n/locales/es.ts b/lib/i18n/locales/es.ts index b46c38d..a919ef9 100644 --- a/lib/i18n/locales/es.ts +++ b/lib/i18n/locales/es.ts @@ -19,6 +19,8 @@ export const es: Dictionary = { map: 'Mapa', journal: 'Diario', skipToContent: 'Saltar al contenido', + mainNavAria: 'Navegación principal', + heroNavAria: 'Secciones', openMenu: 'Abrir menú', closeMenu: 'Cerrar menú', }, @@ -44,12 +46,15 @@ export const es: Dictionary = { nextEntryAria: (title: string) => `Entrada siguiente: ${title}`, entryNavAria: 'Navegación entre entradas del diario', loadingGallery: 'Cargando la galería', + gallery: 'Galería', loadingPhotos: 'Cargando fotos', downloadAlbum: 'Descargar álbum', }, home: { enter: 'Entrar', + pauseSlideshow: 'Pausar la presentación', + playSlideshow: 'Reproducir la presentación', }, error: { @@ -58,8 +63,6 @@ export const es: Dictionary = { errorTitle: 'Algo ha salido mal', errorText: 'No se ha podido cargar esta página. Suele ser algo temporal: inténtalo de nuevo en un momento.', - siteErrorText: - 'No se ha podido cargar este sitio. Suele ser algo temporal: inténtalo de nuevo en un momento.', tryAgain: 'Reintentar', reference: (digest: string) => `Referencia: ${digest}`, }, @@ -71,6 +74,7 @@ export const es: Dictionary = { immichUnavailable: 'La fototeca no está disponible en este momento. Vuelve a intentarlo en breve.', back: 'Volver a la galería', + limitReached: 'Se ha alcanzado el límite de descargas de este enlace.', }, theme: { @@ -82,11 +86,18 @@ export const es: Dictionary = { password: { subtitle: 'Esta galería está protegida con contraseña.', siteSubtitle: 'Este sitio está protegido con contraseña.', + pageSubtitle: 'Esta página está protegida con contraseña.', + journalSubtitle: 'Esta entrada del diario está protegida con contraseña.', + protectedPage: 'Página protegida', placeholder: 'Introduce la contraseña', submit: 'Entrar', verifying: 'Verificando…', incorrect: 'Contraseña incorrecta. Inténtalo de nuevo.', failed: 'No se ha podido verificar la contraseña. Inténtalo más tarde.', + tooManyAttempts: (seconds: number | null) => + seconds + ? `Demasiados intentos. Inténtalo de nuevo en ${seconds} ${seconds === 1 ? 'segundo' : 'segundos'}.` + : 'Demasiados intentos. Inténtalo más tarde.', }, about: { @@ -104,6 +115,9 @@ export const es: Dictionary = { loading: 'Cargando el mapa…', loadFailed: (status: number) => `No se pudieron cargar los datos del mapa (${status})`, initFailed: 'No se pudo inicializar el mapa', + zoomIn: 'Acercar', + zoomOut: 'Alejar', + leafletTitle: 'Una biblioteca de JavaScript para mapas interactivos', }, subpage: { @@ -130,6 +144,7 @@ export const es: Dictionary = { lightbox: { viewer: 'Visor de imágenes', + position: (n: number, total: number) => `Foto ${n} de ${total}`, openPhoto: (n: number) => `Ver foto ${n}`, close: 'Cerrar', closeTitle: 'Cerrar (Esc)', @@ -181,6 +196,7 @@ export const es: Dictionary = { saved: 'Guardado', favorite: 'Favorito', showAll: 'Mostrar todo', + filterEmpty: 'No hay fotos seleccionadas.', selected: (n: number) => `❤️ ${n} ${n === 1 ? 'seleccionada' : 'seleccionadas'}`, shareExport: 'Compartir y exportar', modalTitle: (n: number) => `❤️ Selección (${n})`, @@ -205,6 +221,30 @@ export const es: Dictionary = { `Hola:\n\nEsta es mi selección de fotos:\n\n${list}\n\nEnlace: ${url}\n\nUn saludo,`, }, + proofSession: { + greeting: (name: string) => `Selección para ${name}`, + intro: + 'Toca el corazón en las fotos que quieras. Tu selección se guarda sobre la marcha: puedes volver con este enlace cuando quieras.', + saving: 'Guardando…', + saved: 'Selección guardada', + saveFailed: 'No se ha podido guardar tu selección. Revisa la conexión e inténtalo de nuevo.', + review: 'Revisar y enviar', + modalTitle: (n: number) => `Tu selección (${n})`, + empty: 'Todavía no has seleccionado ninguna foto.', + submit: 'Enviar selección', + submitting: 'Enviando…', + confirmSubmit: (n: number) => + `¿Enviar ${plural(n, 'foto', 'fotos')}? Después no podrás cambiar la selección.`, + submitted: 'Gracias: tu selección se ha enviado.', + locked: 'Esta selección ya se ha enviado y no se puede modificar.', + validUntil: (date: string) => `Este enlace es válido hasta el ${date}.`, + expiredTitle: 'Este enlace ha caducado', + expiredText: 'Pide un enlace nuevo a tu fotógrafo.', + downloadSelection: 'Descargar selección (.zip)', + downloadAll: 'Descargar todas las fotos (.zip)', + downloadsLeft: (n: number) => `${plural(n, 'descarga restante', 'descargas restantes')}`, + }, + legal: { navLabel: 'Aviso legal', title: 'Aviso legal', diff --git a/lib/i18n/locales/fr.ts b/lib/i18n/locales/fr.ts index 6277343..abf5fc1 100644 --- a/lib/i18n/locales/fr.ts +++ b/lib/i18n/locales/fr.ts @@ -8,7 +8,9 @@ import type { Dictionary } from '../index'; -const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`; +// CLDR: French puts 0 in the singular ("0 photo", "0 jour"), unlike English. +const isOne = (n: number) => n === 0 || n === 1; +const plural = (n: number, one: string, other: string) => `${n} ${isOne(n) ? one : other}`; export const fr: Dictionary = { dateLocale: 'fr-FR', @@ -19,6 +21,8 @@ export const fr: Dictionary = { map: 'Carte', journal: 'Journal', skipToContent: 'Aller au contenu', + mainNavAria: 'Navigation principale', + heroNavAria: 'Rubriques', openMenu: 'Ouvrir le menu', closeMenu: 'Fermer le menu', }, @@ -44,12 +48,15 @@ export const fr: Dictionary = { nextEntryAria: (title: string) => `Article suivant : ${title}`, entryNavAria: 'Navigation entre les articles du journal', loadingGallery: 'Chargement de la galerie', + gallery: 'Galerie', loadingPhotos: 'Chargement des photos', downloadAlbum: 'Télécharger l’album', }, home: { enter: 'Entrer', + pauseSlideshow: 'Mettre le diaporama en pause', + playSlideshow: 'Lancer le diaporama', }, error: { @@ -58,8 +65,6 @@ export const fr: Dictionary = { errorTitle: 'Une erreur est survenue', errorText: 'Cette page n’a pas pu être chargée. C’est généralement temporaire — réessayez dans un instant.', - siteErrorText: - 'Ce site n’a pas pu être chargé. C’est généralement temporaire — réessayez dans un instant.', tryAgain: 'Réessayer', reference: (digest: string) => `Référence : ${digest}`, }, @@ -71,6 +76,7 @@ export const fr: Dictionary = { immichUnavailable: 'La photothèque est momentanément indisponible. Veuillez réessayer dans un instant.', back: 'Retour à la galerie', + limitReached: 'La limite de téléchargements de ce lien est atteinte.', }, theme: { @@ -82,11 +88,18 @@ export const fr: Dictionary = { password: { subtitle: 'Cette galerie est protégée par un mot de passe.', siteSubtitle: 'Ce site est protégé par un mot de passe.', + pageSubtitle: 'Cette page est protégée par un mot de passe.', + journalSubtitle: 'Cet article du journal est protégé par un mot de passe.', + protectedPage: 'Page protégée', placeholder: 'Saisissez le mot de passe', submit: 'Entrer', verifying: 'Vérification…', incorrect: 'Mot de passe incorrect. Veuillez réessayer.', failed: 'Impossible de vérifier le mot de passe. Veuillez réessayer plus tard.', + tooManyAttempts: (seconds: number | null) => + seconds + ? `Trop de tentatives. Veuillez réessayer dans ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}.` + : 'Trop de tentatives. Veuillez réessayer plus tard.', }, about: { @@ -104,6 +117,9 @@ export const fr: Dictionary = { loading: 'Chargement de la carte…', loadFailed: (status: number) => `Échec du chargement des données de la carte (${status})`, initFailed: 'Impossible d’initialiser la carte', + zoomIn: 'Zoom avant', + zoomOut: 'Zoom arrière', + leafletTitle: 'Une bibliothèque JavaScript pour cartes interactives', }, subpage: { @@ -130,6 +146,7 @@ export const fr: Dictionary = { lightbox: { viewer: 'Visionneuse d’images', + position: (n: number, total: number) => `Photo ${n} sur ${total}`, openPhoto: (n: number) => `Afficher la photo ${n}`, close: 'Fermer', closeTitle: 'Fermer (Échap)', @@ -182,7 +199,8 @@ export const fr: Dictionary = { saved: 'Enregistré', favorite: 'Favori', showAll: 'Tout afficher', - selected: (n: number) => `❤️ ${n} ${n === 1 ? 'sélectionnée' : 'sélectionnées'}`, + filterEmpty: 'Aucune photo sélectionnée.', + selected: (n: number) => `❤️ ${n} ${isOne(n) ? 'sélectionnée' : 'sélectionnées'}`, shareExport: 'Partager & exporter', modalTitle: (n: number) => `❤️ Sélection (${n})`, closeModal: 'Fermer la fenêtre', @@ -206,6 +224,32 @@ export const fr: Dictionary = { `Bonjour,\n\nVoici ma sélection de photos :\n\n${list}\n\nLien de partage : ${url}\n\nCordialement,`, }, + proofSession: { + greeting: (name: string) => `Sélection pour ${name}`, + intro: + 'Touchez le cœur sur les photos que vous souhaitez. Votre sélection est enregistrée au fur et à mesure — vous pouvez revenir avec ce lien à tout moment.', + saving: 'Enregistrement…', + saved: 'Sélection enregistrée', + saveFailed: + 'Votre sélection n’a pas pu être enregistrée. Vérifiez votre connexion et réessayez.', + review: 'Vérifier & envoyer', + modalTitle: (n: number) => `Votre sélection (${n})`, + empty: 'Aucune photo sélectionnée pour le moment.', + submit: 'Envoyer la sélection', + submitting: 'Envoi…', + confirmSubmit: (n: number) => + `Envoyer ${plural(n, 'photo', 'photos')} ? La sélection ne pourra plus être modifiée ensuite.`, + submitted: 'Merci — votre sélection a été envoyée.', + locked: 'Cette sélection a été envoyée et ne peut plus être modifiée.', + validUntil: (date: string) => `Ce lien est valable jusqu’au ${date}.`, + expiredTitle: 'Ce lien a expiré', + expiredText: 'Demandez un nouveau lien à votre photographe.', + downloadSelection: 'Télécharger la sélection (.zip)', + downloadAll: 'Télécharger toutes les photos (.zip)', + downloadsLeft: (n: number) => + `${plural(n, 'téléchargement restant', 'téléchargements restants')}`, + }, + legal: { navLabel: 'Mentions légales', title: 'Mentions légales', @@ -236,7 +280,7 @@ export const fr: Dictionary = { tooMany: 'Trop de messages en peu de temps. Réessayez dans une minute.', failed: 'Le message n’a pas pu être envoyé. Réessayez plus tard.', privacy: (days: number) => - `Votre message est conservé sur le serveur de ce site, sert uniquement à vous répondre et est supprimé après ${days} ${days === 1 ? 'jour' : 'jours'}.`, + `Votre message est conservé sur le serveur de ce site, sert uniquement à vous répondre et est supprimé après ${days} ${isOne(days) ? 'jour' : 'jours'}.`, }, privacy: { diff --git a/lib/i18n/locales/it.ts b/lib/i18n/locales/it.ts index 0b232f2..a47bf8b 100644 --- a/lib/i18n/locales/it.ts +++ b/lib/i18n/locales/it.ts @@ -19,6 +19,8 @@ export const it: Dictionary = { map: 'Mappa', journal: 'Diario', skipToContent: 'Vai al contenuto', + mainNavAria: 'Navigazione principale', + heroNavAria: 'Sezioni', openMenu: 'Apri menu', closeMenu: 'Chiudi menu', }, @@ -45,12 +47,15 @@ export const it: Dictionary = { nextEntryAria: (title: string) => `Articolo successivo: ${title}`, entryNavAria: 'Navigazione tra gli articoli del diario', loadingGallery: 'Caricamento della galleria', + gallery: 'Galleria', loadingPhotos: 'Caricamento delle foto', downloadAlbum: 'Scarica album', }, home: { enter: 'Entra', + pauseSlideshow: 'Metti in pausa la presentazione', + playSlideshow: 'Avvia la presentazione', }, error: { @@ -59,8 +64,6 @@ export const it: Dictionary = { errorTitle: 'Qualcosa è andato storto', errorText: 'Non è stato possibile caricare questa pagina. Di solito è temporaneo: riprova tra un momento.', - siteErrorText: - 'Non è stato possibile caricare questo sito. Di solito è temporaneo: riprova tra un momento.', tryAgain: 'Riprova', reference: (digest: string) => `Riferimento: ${digest}`, }, @@ -71,6 +74,7 @@ export const it: Dictionary = { rateLimited: 'Troppe richieste di download. Attendi un momento e riprova.', immichUnavailable: 'La libreria fotografica non è al momento disponibile. Riprova a breve.', back: 'Torna alla galleria', + limitReached: 'È stato raggiunto il limite di download per questo link.', }, theme: { @@ -82,11 +86,18 @@ export const it: Dictionary = { password: { subtitle: 'Questa galleria è protetta da password.', siteSubtitle: 'Questo sito è protetto da password.', + pageSubtitle: 'Questa pagina è protetta da password.', + journalSubtitle: 'Questo articolo del diario è protetto da password.', + protectedPage: 'Pagina protetta', placeholder: 'Inserisci la password', submit: 'Entra', verifying: 'Verifica in corso…', incorrect: 'Password errata. Riprova.', failed: 'Impossibile verificare la password. Riprova più tardi.', + tooManyAttempts: (seconds: number | null) => + seconds + ? `Troppi tentativi. Riprova tra ${seconds} ${seconds === 1 ? 'secondo' : 'secondi'}.` + : 'Troppi tentativi. Riprova più tardi.', }, about: { @@ -104,6 +115,9 @@ export const it: Dictionary = { loading: 'Caricamento della mappa…', loadFailed: (status: number) => `Impossibile caricare i dati della mappa (${status})`, initFailed: 'Impossibile inizializzare la mappa', + zoomIn: 'Ingrandisci', + zoomOut: 'Riduci', + leafletTitle: 'Una libreria JavaScript per mappe interattive', }, subpage: { @@ -130,6 +144,7 @@ export const it: Dictionary = { lightbox: { viewer: 'Visualizzatore di immagini', + position: (n: number, total: number) => `Foto ${n} di ${total}`, openPhoto: (n: number) => `Apri la foto ${n}`, close: 'Chiudi', closeTitle: 'Chiudi (Esc)', @@ -181,6 +196,7 @@ export const it: Dictionary = { saved: 'Salvato', favorite: 'Preferito', showAll: 'Mostra tutto', + filterEmpty: 'Nessuna foto selezionata.', selected: (n: number) => `❤️ ${n} ${n === 1 ? 'selezionata' : 'selezionate'}`, shareExport: 'Condividi ed esporta', modalTitle: (n: number) => `❤️ Selezione (${n})`, @@ -205,6 +221,30 @@ export const it: Dictionary = { `Ciao,\n\necco la mia selezione di foto:\n\n${list}\n\nLink di condivisione: ${url}\n\nCordiali saluti,`, }, + proofSession: { + greeting: (name: string) => `Selezione per ${name}`, + intro: + 'Tocca il cuore sulle foto che desideri. La tua selezione viene salvata man mano: puoi tornare con questo link in qualsiasi momento.', + saving: 'Salvataggio…', + saved: 'Selezione salvata', + saveFailed: 'Non è stato possibile salvare la selezione. Controlla la connessione e riprova.', + review: 'Controlla e invia', + modalTitle: (n: number) => `La tua selezione (${n})`, + empty: 'Nessuna foto selezionata.', + submit: 'Invia selezione', + submitting: 'Invio in corso…', + confirmSubmit: (n: number) => + `Inviare ${n} foto? Dopo non sarà più possibile modificare la selezione.`, + submitted: 'Grazie, la tua selezione è stata inviata.', + locked: 'Questa selezione è stata inviata e non può più essere modificata.', + validUntil: (date: string) => `Questo link è valido fino al ${date}.`, + expiredTitle: 'Questo link è scaduto', + expiredText: 'Chiedi un nuovo link al tuo fotografo.', + downloadSelection: 'Scarica selezione (.zip)', + downloadAll: 'Scarica tutte le foto (.zip)', + downloadsLeft: (n: number) => `${plural(n, 'download rimasto', 'download rimasti')}`, + }, + legal: { navLabel: 'Note legali', title: 'Note legali', diff --git a/lib/i18n/locales/nl.ts b/lib/i18n/locales/nl.ts index 38beb88..3177714 100644 --- a/lib/i18n/locales/nl.ts +++ b/lib/i18n/locales/nl.ts @@ -19,6 +19,8 @@ export const nl: Dictionary = { map: 'Kaart', journal: 'Journal', skipToContent: 'Naar de inhoud', + mainNavAria: 'Hoofdnavigatie', + heroNavAria: 'Onderdelen', openMenu: 'Menu openen', closeMenu: 'Menu sluiten', }, @@ -44,12 +46,15 @@ export const nl: Dictionary = { nextEntryAria: (title: string) => `Volgend verhaal: ${title}`, entryNavAria: 'Navigatie tussen journalverhalen', loadingGallery: 'Galerij laden', + gallery: 'Galerie', loadingPhotos: 'Foto’s laden', downloadAlbum: 'Album downloaden', }, home: { enter: 'Binnenkomen', + pauseSlideshow: 'Diavoorstelling pauzeren', + playSlideshow: 'Diavoorstelling afspelen', }, error: { @@ -58,8 +63,6 @@ export const nl: Dictionary = { errorTitle: 'Er is iets misgegaan', errorText: 'Deze pagina kon niet worden geladen. Meestal is dat tijdelijk — probeer het zo nog eens.', - siteErrorText: - 'Deze site kon niet worden geladen. Meestal is dat tijdelijk — probeer het zo nog eens.', tryAgain: 'Opnieuw proberen', reference: (digest: string) => `Referentie: ${digest}`, }, @@ -71,6 +74,7 @@ export const nl: Dictionary = { immichUnavailable: 'De fotobibliotheek is op dit moment niet bereikbaar. Probeer het zo opnieuw.', back: 'Terug naar de galerij', + limitReached: 'Het downloadlimiet voor deze link is bereikt.', }, theme: { @@ -82,11 +86,18 @@ export const nl: Dictionary = { password: { subtitle: 'Deze galerij is met een wachtwoord beveiligd.', siteSubtitle: 'Deze site is met een wachtwoord beveiligd.', + pageSubtitle: 'Deze pagina is met een wachtwoord beveiligd.', + journalSubtitle: 'Dit journalverhaal is met een wachtwoord beveiligd.', + protectedPage: 'Beveiligde pagina', placeholder: 'Voer het wachtwoord in', submit: 'Binnenkomen', verifying: 'Controleren…', incorrect: 'Onjuist wachtwoord. Probeer het opnieuw.', failed: 'Het wachtwoord kon niet worden gecontroleerd. Probeer het later opnieuw.', + tooManyAttempts: (seconds: number | null) => + seconds + ? `Te veel pogingen. Probeer het over ${seconds} ${seconds === 1 ? 'seconde' : 'seconden'} opnieuw.` + : 'Te veel pogingen. Probeer het later opnieuw.', }, about: { @@ -104,6 +115,9 @@ export const nl: Dictionary = { loading: 'Kaart laden…', loadFailed: (status: number) => `Kaartgegevens konden niet worden geladen (${status})`, initFailed: 'De kaart kon niet worden gestart', + zoomIn: 'Inzoomen', + zoomOut: 'Uitzoomen', + leafletTitle: 'Een JavaScript-bibliotheek voor interactieve kaarten', }, subpage: { @@ -130,6 +144,7 @@ export const nl: Dictionary = { lightbox: { viewer: 'Fotoviewer', + position: (n: number, total: number) => `Foto ${n} van ${total}`, openPhoto: (n: number) => `Foto ${n} bekijken`, close: 'Sluiten', closeTitle: 'Sluiten (Esc)', @@ -182,6 +197,7 @@ export const nl: Dictionary = { saved: 'Opgeslagen', favorite: 'Favoriet', showAll: 'Alles tonen', + filterEmpty: "Geen foto's geselecteerd.", selected: (n: number) => `❤️ ${n} geselecteerd`, shareExport: 'Delen & exporteren', modalTitle: (n: number) => `❤️ Selectie (${n})`, @@ -206,6 +222,31 @@ export const nl: Dictionary = { `Hallo,\n\nHier is mijn fotoselectie:\n\n${list}\n\nDeellink: ${url}\n\nMet vriendelijke groet,`, }, + proofSession: { + greeting: (name: string) => `Selectie voor ${name}`, + intro: + 'Tik op het hartje bij de foto’s die je wilt. Je selectie wordt meteen opgeslagen — je kunt met deze link altijd terugkomen.', + saving: 'Opslaan…', + saved: 'Selectie opgeslagen', + saveFailed: + 'Je selectie kon niet worden opgeslagen. Controleer je verbinding en probeer het opnieuw.', + review: 'Bekijken & versturen', + modalTitle: (n: number) => `Jouw selectie (${n})`, + empty: 'Nog geen foto’s geselecteerd.', + submit: 'Selectie versturen', + submitting: 'Versturen…', + confirmSubmit: (n: number) => + `${plural(n, 'foto', 'foto’s')} versturen? Daarna kun je de selectie niet meer wijzigen.`, + submitted: 'Bedankt — je selectie is verstuurd.', + locked: 'Deze selectie is verstuurd en kan niet meer worden gewijzigd.', + validUntil: (date: string) => `Deze link is geldig tot ${date}.`, + expiredTitle: 'Deze link is verlopen', + expiredText: 'Vraag je fotograaf om een nieuwe link.', + downloadSelection: 'Selectie downloaden (.zip)', + downloadAll: 'Alle foto’s downloaden (.zip)', + downloadsLeft: (n: number) => `Nog ${plural(n, 'download', 'downloads')}`, + }, + legal: { navLabel: 'Colofon', title: 'Colofon', diff --git a/lib/imageSize.ts b/lib/imageSize.ts index 9a35c37..c9c6a00 100644 --- a/lib/imageSize.ts +++ b/lib/imageSize.ts @@ -62,3 +62,32 @@ export function resolveImageSize(sizeParam: string | null, widthParam: string | explicit && fromWidth ? smaller(explicit, fromWidth) : (explicit ?? fromWidth ?? 'preview'); return smaller(resolved, MAX_SIZE); } + +/** + * The `?w=` written for each tier: the widest value that still resolves to it. + * `resolveImageSize(size, String(TIER_WIDTH[t]))` is `t` for every tier the + * ceiling allows, so the canonical URL fetches exactly the rendition the + * original width did. + */ +const TIER_WIDTH: Record = { thumbnail: 250, preview: 1440, original: 3840 }; + +/** + * One URL per asset and tier, whatever width is asked for. + * + * The proxy answers every width inside a tier with the same bytes — Immich + * has one thumbnail and one preview per asset, and `MAX_SIZE` caps the rest — + * but each distinct `?w=` is a separate `immutable` cache entry. next/image + * writes eight or more widths into every srcset and the lightbox asked for the + * bare URL, so a grid tile and the same photo in the lightbox, or one tile + * before and after a rotation, downloaded the identical preview twice. + * Collapsing the width to its tier makes all of them one cache entry. `q` is + * dropped for the same reason: the proxy never read it. + * + * `width` defaults to "as large as the ceiling allows" — the lightbox's case. + */ +export function canonicalImageUrl(src: string, width: number = Number.MAX_SAFE_INTEGER): string { + const queryAt = src.indexOf('?'); + const sizeParam = queryAt === -1 ? null : new URLSearchParams(src.slice(queryAt + 1)).get('size'); + const tier = resolveImageSize(sizeParam, String(Math.max(1, Math.round(width)))); + return `${src}${queryAt === -1 ? '?' : '&'}w=${TIER_WIDTH[tier]}`; +} diff --git a/lib/immich.ts b/lib/immich.ts index 1266511..ab1ae5f 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -11,7 +11,7 @@ * cached answer may still be served in ./immichCache (#610). */ -import { getConfig, albumSlug, normalizeSlug, type SubpageConfig } from './config'; +import { getConfig, albumSlug, normalizeSlug, type AppConfig, type SubpageConfig } from './config'; import { cache } from './cache'; import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort'; import { ImmichUnavailableError, isTimeout, requestJson } from './immichTransport'; @@ -88,6 +88,8 @@ export type ImageSize = 'thumbnail' | 'preview' | 'original'; /** Enriched subpage with album metadata (for rendering cards). */ export interface SubpageSummary { name: string; + /** The display title from gallery.yaml; visitors see this, `name` only as fallback. */ + title?: string; slug: string; albumCount: number; totalAssetCount: number; @@ -96,6 +98,16 @@ export interface SubpageSummary { // Note: Album summary logging state is kept in the client instance. +/** Immich's album list as cached, with the allowlist it was fetched for. */ +interface CachedAlbumList { + albums: ImmichAlbum[]; + fetchedFor: string; +} + +function allowlistKey(config: AppConfig): string { + return [...config.albums].sort().join(','); +} + // ── API Client ───────────────────────────────────────────────── class ImmichClient { @@ -105,6 +117,11 @@ class ImmichClient { private pendingAlbumPromises = new Map>(); private pendingAssetPromises = new Map>(); + /** + * getConfig() re-checks the content files and hands back a fresh copy on + * every call, so a method reads it once into a local and uses that, rather + * than going through this getter for each property. + */ private get config() { return getConfig(); } @@ -118,8 +135,8 @@ class ImmichClient { * made every call return null before the fetch, so `ping()` reported the * server as disconnected without a single line in the log (#507). */ - private get hasCredentials(): boolean { - const { apiUrl, apiKey } = this.config.immich; + private hasCredentials(config: AppConfig): boolean { + const { apiUrl, apiKey } = config.immich; return !!apiUrl && !!apiKey; } @@ -134,8 +151,8 @@ class ImmichClient { } /** Cache write that carries the configured stale window. */ - private cacheSet(key: string, data: T): void { - cacheSetWithStale(key, data, this.config.cacheTtl, this.config.staleMaxAge); + private cacheSet(key: string, data: T, config: AppConfig = this.config): void { + cacheSetWithStale(key, data, config.cacheTtl, config.staleMaxAge); } /** See ./immichCache — the policy lives there, testable on its own. */ @@ -156,15 +173,16 @@ class ImmichClient { * deployment rather than about HTTP. */ private async request(endpoint: string, body?: unknown): Promise { - if (!this.hasCredentials) { + const config = this.config; + if (!this.hasCredentials(config)) { this.warnNoCredentials(endpoint); return null; } return requestJson({ - apiUrl: this.config.immich.apiUrl, - apiKey: this.config.immich.apiKey, - timeoutMs: this.config.immichTimeoutMs, + apiUrl: config.immich.apiUrl, + apiKey: config.immich.apiKey, + timeoutMs: config.immichTimeoutMs, endpoint, body, }); @@ -184,21 +202,22 @@ class ImmichClient { contentRange: string | null; status: 200 | 206; } | null> { - if (!this.hasCredentials) return null; + const config = this.config; + if (!this.hasCredentials(config)) return null; const endpoint = `/assets/${encodeURIComponent(assetId)}/video/playback`; - const url = `${this.config.immich.apiUrl}${endpoint}`; + const url = `${config.immich.apiUrl}${endpoint}`; // Bound the wait for response *headers* only. The `finally` clears the timer // the moment they arrive, so the body may then stream for as long as it // needs — a whole-request timeout would truncate playback mid-video and // break seeking, since every range request would restart the clock. const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.config.immichTimeoutMs); + const timer = setTimeout(() => controller.abort(), config.immichTimeoutMs); try { const headers: Record = { - 'x-api-key': this.config.immich.apiKey, + 'x-api-key': config.immich.apiKey, }; if (rangeHeader) { headers['Range'] = rangeHeader; @@ -235,13 +254,13 @@ class ImmichClient { if (error instanceof ImmichUnavailableError) throw error; console.error( isTimeout(error) - ? `[Immich] Video ${assetId} did not respond within ${this.config.immichTimeoutMs}ms` + ? `[Immich] Video ${assetId} did not respond within ${config.immichTimeoutMs}ms` : `[Immich] Video stream error for ${assetId}:`, error, ); throw new ImmichUnavailableError( isTimeout(error) - ? `Immich did not respond within ${this.config.immichTimeoutMs}ms for video ${assetId}` + ? `Immich did not respond within ${config.immichTimeoutMs}ms for video ${assetId}` : `Cannot reach Immich to stream video ${assetId}`, ); } finally { @@ -256,24 +275,25 @@ class ImmichClient { assetId: string, size: ImageSize = 'preview', ): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> { - if (!this.hasCredentials) return null; + const config = this.config; + if (!this.hasCredentials(config)) return null; const endpoint = size === 'original' ? `/assets/${encodeURIComponent(assetId)}/original` : `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`; - const url = `${this.config.immich.apiUrl}${endpoint}`; + const url = `${config.immich.apiUrl}${endpoint}`; // Headers-only timeout, same reasoning as streamVideo: an original-size // photo is legitimately slow to transfer and must not be capped. const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.config.immichTimeoutMs); + const timer = setTimeout(() => controller.abort(), config.immichTimeoutMs); try { const res = await fetch(url, { headers: { - 'x-api-key': this.config.immich.apiKey, + 'x-api-key': config.immich.apiKey, }, signal: controller.signal, }); @@ -305,13 +325,13 @@ class ImmichClient { if (error instanceof ImmichUnavailableError) throw error; console.error( isTimeout(error) - ? `[Immich] Asset ${assetId} did not respond within ${this.config.immichTimeoutMs}ms` + ? `[Immich] Asset ${assetId} did not respond within ${config.immichTimeoutMs}ms` : `[Immich] Stream error for ${assetId}:`, error, ); throw new ImmichUnavailableError( isTimeout(error) - ? `Immich did not respond within ${this.config.immichTimeoutMs}ms for asset ${assetId}` + ? `Immich did not respond within ${config.immichTimeoutMs}ms for asset ${assetId}` : `Cannot reach Immich to stream asset ${assetId}`, ); } finally { @@ -320,10 +340,63 @@ class ImmichClient { } /** - * Get ALL configured albums (filtered by the full allowlist). - * Uses ?shared=true to only fetch albums that have been shared in Immich. + * Every published album: the allowlist applied to Immich's album list, with + * the gallery.yaml title and description overrides and the resulting slug. + * + * The cache holds Immich's list as Immich sent it; the allowlist and the + * overrides are applied here, on every call, from the current config. They + * used to be applied before caching, and the admin routes' invalidateAll() + * runs in their own module instances (Next bundles each route separately), + * so after a save the public pages kept the old titles, descriptions and + * album set until CACHE_TTL ran out. getConfig() already follows the + * content files' mtimes into every instance, so deriving from it per call + * makes a save visible on the next request, at no Immich cost. + * + * A newly allowlisted album that is missing from the cached list (created + * in Immich after the list was fetched) refetches the list once per + * allowlist, instead of 404-ing until the TTL runs out; see listIsCurrent(). + * + * `?shared=true` is inert: current Immich ignores it and answers with every + * album (#515). The allowlist is the whole protection. */ async getAlbums(forceFresh = false): Promise { + const config = this.config; + const list = await this.loadAlbumList(forceFresh, config); + return this.publishedAlbums(list, config); + } + + /** The allowlisted albums of a raw Immich list, named per gallery.yaml. */ + private publishedAlbums(all: readonly ImmichAlbum[], config: AppConfig): ImmichAlbum[] { + const allowedIds = new Set(config.albums); + return all + .filter((album) => allowedIds.has(album.id)) + .map((album) => this.withOverrides(album, config)); + } + + /** + * An album named as gallery.yaml says. Returns a copy: the argument is the + * cached entry, shared by every caller. + */ + private withOverrides(album: ImmichAlbum, config: AppConfig): ImmichAlbum { + const name = config.albumOverrides[album.id] ?? album.albumName; + const description = config.albumDescriptions[album.id] ?? album.description ?? ''; + return { ...album, albumName: name, description, slug: albumSlug(name, album.id) }; + } + + /** + * Whether a cached list can answer for this allowlist: it holds every + * allowlisted album, or it was already fetched for this exact allowlist + * (an album deleted in Immich stays missing, and must not cost a request + * per page view). + */ + private listIsCurrent(entry: CachedAlbumList, config: AppConfig): boolean { + if (entry.fetchedFor === allowlistKey(config)) return true; + const known = new Set(entry.albums.map((a) => a.id)); + return config.albums.every((id) => known.has(id)); + } + + /** Immich's album list, cached as sent. */ + private async loadAlbumList(forceFresh: boolean, config: AppConfig): Promise { const cacheKey = 'albums-list'; // forceFresh only skips the read below; it must not delete the entry // outright. Deleting first meant a request that then failed left every @@ -331,8 +404,8 @@ class ImmichClient { // from the same key, so the old entry has to survive until cacheSet() // overwrites it on success. if (!forceFresh) { - const cached = cache.get(cacheKey); - if (cached) return cached; + const cached = cache.get(cacheKey); + if (cached && this.listIsCurrent(cached, config)) return cached.albums; } if (this.pendingAlbumsPromise) { @@ -344,66 +417,20 @@ class ImmichClient { const all = await this.request('/albums?shared=true'); if (!all) return []; - const allowedIds = new Set(this.config.albums); - const filtered = all - .filter((album) => allowedIds.has(album.id)) - .map((album) => { - const name = this.config.albumOverrides[album.id] ?? album.albumName; - const description = this.config.albumDescriptions[album.id] ?? album.description ?? ''; - return { - ...album, - albumName: name, - description, - slug: albumSlug(name, album.id), - }; - }); - // Log album summary on first load so admins can see what's published if (!this.hasLoggedAlbums) { this.hasLoggedAlbums = true; - console.log('\n[Lightbox] Published albums:'); - console.log('─'.repeat(80)); - - // Log standalone albums - const standaloneIds = new Set(this.config.standaloneAlbums); - const standalone = filtered.filter((a) => standaloneIds.has(a.id)); - if (standalone.length > 0) { - console.log(' Standalone:'); - for (const a of standalone) { - console.log(` 📷 ${a.albumName}`); - console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`); - } - } - - // Log subpage groupings - for (const sp of this.config.subpages) { - const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id)); - console.log(` 📁 ${sp.name} (/${sp.slug}):`); - for (const a of spAlbums) { - console.log(` 📷 ${a.albumName}`); - console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`); - } - } - - const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id)); - if (missing.length > 0) { - console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`); - } - console.log('─'.repeat(80) + '\n'); + this.logPublishedAlbums(all, config); } - // Not while the install is unfinished. The dummy setup config carries an - // empty allowlist, so `filtered` is [] no matter what Immich returned — - // and caching that outlives the wizard that fixes it, because - // invalidateAll() runs in the install route's own module instance - // (Next bundles each route separately; see lib/install.ts). The gallery - // then looks empty until the server restarts. - if (!this.config.needsSetup) { - this.cacheSet(cacheKey, filtered); - } - return filtered; + // Cached even while setup is unfinished: the list is Immich's, and the + // setup config's empty allowlist is applied on the way out, not baked + // into the entry. + const entry: CachedAlbumList = { albums: all, fetchedFor: allowlistKey(config) }; + this.cacheSet(cacheKey, entry, config); + return all; } catch (error) { - return this.staleOrThrow(cacheKey, error, 'album list'); + return this.staleOrThrow(cacheKey, error, 'album list').albums; } finally { this.pendingAlbumsPromise = null; } @@ -412,6 +439,40 @@ class ImmichClient { return this.pendingAlbumsPromise; } + /** The published albums, once per process, so admins can see what is live. */ + private logPublishedAlbums(all: readonly ImmichAlbum[], config: AppConfig): void { + const filtered = this.publishedAlbums(all, config); + console.log('\n[Lightbox] Published albums:'); + console.log('─'.repeat(80)); + + // Log standalone albums + const standaloneIds = new Set(config.standaloneAlbums); + const standalone = filtered.filter((a) => standaloneIds.has(a.id)); + if (standalone.length > 0) { + console.log(' Standalone:'); + for (const a of standalone) { + console.log(` 📷 ${a.albumName}`); + console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`); + } + } + + // Log subpage groupings + for (const sp of config.subpages) { + const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id)); + console.log(` 📁 ${sp.name} (/${sp.slug}):`); + for (const a of spAlbums) { + console.log(` 📷 ${a.albumName}`); + console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`); + } + } + + const missing = config.albums.filter((id) => !all.some((a) => a.id === id)); + if (missing.length > 0) { + console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`); + } + console.log('─'.repeat(80) + '\n'); + } + /** * Get only standalone albums (not in any subpage) — for the homepage. */ @@ -446,6 +507,7 @@ class ImmichClient { return { name: sp.name, + ...(sp.title ? { title: sp.title } : {}), slug: sp.slug, albumCount: spAlbums.length, totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0), @@ -465,9 +527,13 @@ class ImmichClient { const subpage = this.config.subpages.find((sp) => sp.slug === wanted && sp.enabled !== false); if (!subpage) return null; - const allAlbums = await this.getAlbums(forceFresh); - const subpageAlbumIds = new Set(subpage.albumIds); - const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id)); + // In the subpage's own order (gallery.yaml, as arranged in the page + // builder), not Immich's: filtering the list kept Immich's order, so the + // cover grid ignored every rearrangement. + const byId = new Map((await this.getAlbums(forceFresh)).map((a) => [a.id, a])); + const albums = [...new Set(subpage.albumIds)] + .map((id) => byId.get(id)) + .filter((a): a is ImmichAlbum => a !== undefined); return { subpage, albums }; } @@ -526,20 +592,23 @@ class ImmichClient { * saved config, which is right for the public site but wrong for the page * builder: an album just dragged in has not been saved yet, so the picker * and the reorder editor would come up empty. Admin auth is the gate here. + * + * The public site uses it too: album blocks in journal entries, content + * pages and essay files, and the published-asset set that covers them. The + * author's pick is the gate there, not the allowlist. + * + * It goes through the same cached, coalesced load as getAlbum(), so those + * renders cost no Immich request within CACHE_TTL, and an album that is also + * allowlisted is fetched once for both. That load yields exactly this list — + * trashed assets dropped, sorted by capture time in the album's own order — + * which is the order the site renders under `sort: immich`, and the baseline + * the reorder editor has to match. `forceFresh` skips the cached read for + * the admin editors, which must show an album as Immich has it now. */ - async getAlbumAssetsRaw(albumId: string): Promise { - const [album, assets] = await Promise.all([ - this.request(`/albums/${encodeURIComponent(albumId)}`), - this.fetchAlbumAssets(albumId), - ]); - if (!album) return []; - - // Same order the site would render under `sort: immich`. The reorder - // editor's baseline has to match it exactly, or the assets it shows as - // "follows automatically" would not be the ones that actually follow. - return assets - .filter((a) => !a.isTrashed) - .sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1)); + async getAlbumAssetsRaw(albumId: string, forceFresh = false): Promise { + const album = await this.loadAlbum(albumId, forceFresh, true); + // A copy: the cached entry is shared with every other caller. + return album ? [...album.assets] : []; } /** @@ -555,13 +624,17 @@ class ImmichClient { * be sorting the same array. */ private withSort(album: ImmichAlbum): ImmichAlbum { - const mode = this.config.albumSortModes[album.id] ?? DEFAULT_ALBUM_SORT; + const config = this.config; + const mode = config.albumSortModes[album.id] ?? DEFAULT_ALBUM_SORT; return { - ...album, + // Titles and descriptions the same way, for the same reason: the cache + // holds the album as Immich has it, so a gallery.yaml override takes + // effect on the next request, in every module instance. + ...this.withOverrides(album, config), assets: sortAlbumAssets(album.assets, { mode, immichOrder: album.order, - manualOrder: this.config.albumManualOrders[album.id], + manualOrder: config.albumManualOrders[album.id], }), }; } @@ -571,10 +644,28 @@ class ImmichClient { return album ? this.withSort(album) : null; } + /** + * An album for a client proofing link, whether or not it is published. + * + * Client galleries are usually not part of the public portfolio, so the + * allowlist cannot be the gate here. The proofing session is: callers reach + * this only after resolving a valid, unexpired session token, and pass that + * session's own album ID — never one taken from the request. + */ + async getProofingAlbum(albumId: string): Promise { + const album = await this.loadAlbum(albumId, false, true); + return album ? this.withSort(album) : null; + } + /** The cached load. Always yields the canonical Immich order; see withSort(). */ - private async loadAlbum(albumId: string, forceFresh: boolean): Promise { + private async loadAlbum( + albumId: string, + forceFresh: boolean, + /** Only for getProofingAlbum() and getAlbumAssetsRaw(); see there. */ + bypassAllowlist = false, + ): Promise { // Security: only serve configured albums - if (!this.config.albums.includes(albumId)) { + if (!bypassAllowlist && !this.config.albums.includes(albumId)) { console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`); return null; } @@ -627,12 +718,8 @@ class ImmichClient { // getAlbum(); see withSort(). album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1)); - const name = this.config.albumOverrides[album.id] ?? album.albumName; - const description = this.config.albumDescriptions[album.id] ?? album.description ?? ''; - album.albumName = name; - album.description = description; - album.slug = albumSlug(name, album.id); - + // Cached without the gallery.yaml title and description: withSort() + // applies them on the way out, from the current config. this.cacheSet(cacheKey, album); return album; } catch (error) { @@ -662,16 +749,17 @@ class ImmichClient { ): Promise { const albums = await this.getAlbums(forceFresh); + const config = this.config; let routeIds: Set; if (subpageSlug) { const wantedSubpage = normalizeSlug(subpageSlug); - const subpage = this.config.subpages.find( + const subpage = config.subpages.find( (sp) => sp.slug === wantedSubpage && sp.enabled !== false, ); if (!subpage) return null; routeIds = new Set(subpage.albumIds); } else { - routeIds = new Set(this.config.standaloneAlbums); + routeIds = new Set(config.standaloneAlbums); } const searchSet = albums.filter((a) => routeIds.has(a.id)); diff --git a/lib/immichLoader.ts b/lib/immichLoader.ts index 0d3a8c7..bd3c143 100644 --- a/lib/immichLoader.ts +++ b/lib/immichLoader.ts @@ -2,23 +2,24 @@ * Custom next/image loader for Immich proxy. * * Referenced by next.config.ts via images.loaderFile. - * Generates URLs that point to our /api/image proxy route, - * passing width and quality as query parameters. + * Generates URLs that point to our /api/image proxy route. * * The src passed to next/image should already be a full proxy URL - * (e.g. /api/image/?size=preview). This loader appends - * w and q params for responsive srcset generation. + * (e.g. /api/image/?size=preview). This loader appends the width — + * collapsed to the width of the Immich tier it resolves to, so every srcset + * candidate of one tier is the same URL (see canonicalImageUrl). */ 'use client'; +import { canonicalImageUrl } from './imageSize'; + interface ImageLoaderParams { src: string; width: number; quality?: number; } -export default function immichLoader({ src, width, quality }: ImageLoaderParams): string { - const separator = src.includes('?') ? '&' : '?'; - return `${src}${separator}w=${width}&q=${quality || 75}`; +export default function immichLoader({ src, width }: ImageLoaderParams): string { + return canonicalImageUrl(src, width); } diff --git a/lib/install.ts b/lib/install.ts index 388ffcd..93cad16 100644 --- a/lib/install.ts +++ b/lib/install.ts @@ -125,8 +125,13 @@ interface InstallFileData { let cachedInstallFile: { path: string; mtimeMs: number; data: InstallFileData } | null = null; +/** Where install.json lives; getConfig() watches its mtime. */ +export function installFilePath(): string { + return path.join(installContentDir(), INSTALL_FILENAME); +} + function readInstallFile(): InstallFileData { - const installPath = path.join(installContentDir(), INSTALL_FILENAME); + const installPath = installFilePath(); let mtimeMs = 0; try { diff --git a/lib/journal.server.ts b/lib/journal.server.ts new file mode 100644 index 0000000..beb6191 --- /dev/null +++ b/lib/journal.server.ts @@ -0,0 +1,23 @@ +/** + * Journal reads for server rendering, memoised for the length of one request. + * + * A journal page reads the same files from several places in one render: the + * header nav (`loadSiteNav()` in the root layout) lists every entry to decide + * whether Journal belongs in the menu, the page lists them again for its + * index or its prev/next links, and an entry's `generateMetadata()` reads the + * entry the page body reads a moment later. React's `cache()` hands all of + * them one result per request — each request still reads the files afresh, + * so an edit shows on the next page load as before. + * + * Render only: outside a React server render `cache()` does not memoise, and + * admin routes, which read back what they just wrote, keep calling + * `lib/admin/journal-service` directly. Callers must not mutate the results, + * which are shared. + */ + +import { cache } from 'react'; +import { listJournalEntries, readJournalEntry } from './admin/journal-service'; + +export const listJournalEntriesForRequest = cache(listJournalEntries); + +export const readJournalEntryForRequest = cache(readJournalEntry); diff --git a/lib/journal.ts b/lib/journal.ts index 8d56e0c..eebbd11 100644 --- a/lib/journal.ts +++ b/lib/journal.ts @@ -6,6 +6,8 @@ export interface JournalFrontmatter { title?: string; subtitle?: string; + /** SEO description. Content pages use it (#722); journal entries ignore it. */ + description?: string; date?: string; author?: string; coverAssetId?: string; @@ -184,14 +186,28 @@ export function isValidSlug(slug: string): boolean { } export function sanitizeSlug(input: string): string { - return ( - input - .toLowerCase() - .trim() - .replace(/[^a-z0-9_-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') || 'untitled' - ); + return slugFieldValue(input.trim()).replace(/-$/, '') || 'untitled'; +} + +/** + * A slug field's value while it is being typed: sanitizeSlug() without the two + * steps that only suit a finished slug. Dropping the trailing "-" on every + * keystroke made a hyphen impossible to type ("about-" became "about", and the + * next key gave "aboutu"), and an emptied field jumped to "untitled". Pass the + * result through sanitizeSlug() before using it as a slug. + * + * Accented letters keep their base letter ("für" → "fur", "Straße" → + * "strasse"), as subpage slugs do; before, each one split the word ("f-r"). + */ +export function slugFieldValue(input: string): string { + return input + .replace(/ß/g, 'ss') + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-/, ''); } /** Simple Markdown inline formatting (bold, italic, links) */ @@ -216,8 +232,11 @@ export function renderInlineMarkdown(text: string): string { // Italic: *text* or _text_, same intraword rule for `_`. html = html.replace(/\*(.*?)\*/g, '$1'); html = html.replace(/(?$1'); - // Links: [label](url) - html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => { + // Links: [label](url). The URL may hold one level of balanced parentheses, + // as Wikipedia-style URLs do (`…/Foo_(bar)`); it used to end at the first + // `)` and leave the rest as text. The two alternatives start on disjoint + // characters, so this stays linear. + html = html.replace(/\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)/g, (match, label, url) => { const trimmed = url.trim(); if (!isSafeUrl(trimmed)) return label; return `${label}`; @@ -365,6 +384,7 @@ export function parseFrontmatter(content: string): { if (key === 'title') frontmatter.title = val; else if (key === 'subtitle') frontmatter.subtitle = val; + else if (key === 'description') frontmatter.description = val; else if (key === 'date') frontmatter.date = val; else if (key === 'author') frontmatter.author = val; else if (key === 'coverAssetId') frontmatter.coverAssetId = val; @@ -376,6 +396,63 @@ export function parseFrontmatter(content: string): { return { frontmatter, body }; } +/** + * Split a chunk before every line that starts a photo reference (`![`). + * + * Paragraphs are separated by blank lines, so two photo lines written without + * one, or a heading or sentence directly above a photo, used to arrive as a + * single chunk. No block pattern matched it and it fell through to the + * paragraph renderer, which printed the raw asset ids on the public page. + * Directive chunks (`::map`, `::facts`, `::album`) are left alone: their + * lines are key/value pairs, never photo references. + */ +function splitAtPhotoLines(chunk: string): string[] { + if (chunk.startsWith('::') || !/(?:^|\n)[ \t]*!\[/.test(chunk)) return [chunk]; + const segments: string[] = []; + let current: string[] = []; + for (const line of chunk.split(/\r?\n/)) { + if (line.trimStart().startsWith('![') && current.length > 0) { + segments.push(current.join('\n').trim()); + current = []; + } + current.push(line); + } + segments.push(current.join('\n').trim()); + return segments.filter(Boolean); +} + +/** + * Split a photo line, `![]()`, into target and caption. + * + * The target ends at the first `]` on the line: asset ids, layouts and commas + * never contain one. The caption is everything after `](` up to the chunk's + * **last** `)`, so it may itself contain parentheses and links (`Gipfel + * (560 m)`, `see [map](https://…)`) and needs no escaping. The previous + * pattern, `\(([^)]*)\)$`, stopped at the first `)`. The serializer wrote such + * captions verbatim, the next parse rejected the line, the public page printed + * it as a paragraph with the raw asset ids, and the editor turned the photo + * into a text block. A line that pattern accepted has no `)` inside its + * caption, so it parses exactly as before. + * + * Every chunk starting with `![` is a photo line, even a malformed one (no + * `(`, text after the last `)`): it becomes a photo block whose caption keeps + * the stray text, never a paragraph that would print the target. + */ +function parsePhotoLine(chunk: string): { target: string; caption: string } | null { + if (!chunk.startsWith('![')) return null; + const firstLineEnd = chunk.indexOf('\n'); + const firstLine = firstLineEnd === -1 ? chunk : chunk.slice(0, firstLineEnd); + const close = firstLine.indexOf(']'); + const targetEnd = close === -1 ? firstLine.length : close; + const target = chunk.slice(2, targetEnd).trim(); + let rest = chunk.slice(close === -1 ? targetEnd : targetEnd + 1); + if (rest.startsWith('(')) { + rest = rest.slice(1); + if (rest.endsWith(')')) rest = rest.slice(0, -1); + } + return { target, caption: rest.trim() }; +} + /** Parse Journal Markdown content into structured blocks */ export function parseJournalMarkdown(rawContent: string): ParsedJournal { const { frontmatter, body } = parseFrontmatter(rawContent); @@ -385,7 +462,8 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal { const chunks = body .split(/\r?\n\s*\r?\n/) .map((c) => c.trim()) - .filter(Boolean); + .filter(Boolean) + .flatMap(splitAtPhotoLines); for (const chunk of chunks) { // 0a. Map: `::map Caption`, then one line per pin in drawing order — @@ -526,10 +604,12 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal { // unfilled placeholder — `assetId: ''`, serialized as `![](Caption)` or // `![:wide](Caption)` — still parses back into a `photo` block instead of // silently degrading into a text paragraph on the next load. - const imgMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]*)\)$/); - if (imgMatch) { - const rawTarget = imgMatch[1].trim(); - const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined; + // + // parsePhotoLine() has the caption run to the last `)`, see there. + const photoLine = parsePhotoLine(chunk); + if (photoLine) { + const rawTarget = photoLine.target; + const caption = photoLine.caption ? renderInlineMarkdown(photoLine.caption) : undefined; // Two ids are a side-by-side pair, three or more a grid. The pair used // to take the first two of any count and drop the rest on the next save. @@ -704,7 +784,9 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string { export function calculateReadingTime(text: string): { words: number; minutes: number } { const plainText = text .replace(/<[^>]+>/g, ' ') - .replace(/!\[.*?\]\(.*?\)/g, ' ') + // A photo line is a reference and its caption, to the end of the line: + // the caption may itself contain `)`. + .replace(/^[ \t]*!\[.*$/gm, ' ') // Directive lines (`::facts`, `::map …`) are structure, not reading. .replace(/^::\w+.*$/gm, ' '); const words = plainText.trim().split(/\s+/).filter(Boolean).length; diff --git a/lib/journalDate.ts b/lib/journalDate.ts new file mode 100644 index 0000000..cde9804 --- /dev/null +++ b/lib/journalDate.ts @@ -0,0 +1,49 @@ +/** + * Display form of a journal entry's `date:` frontmatter. Client-safe (no `fs`), + * shared by the /journal index and the entry header so both show the same thing. + * + * The value is typed by hand, so it is read as a calendar date, not an instant: + * + * - `2026-03-15` (optionally followed by a time) is formatted in UTC. `new Date()` + * reads a date-only ISO string as UTC midnight, and formatting that in the + * server's or browser's timezone showed Mar 14 west of Greenwich. + * - `2026-03` shows month and year — `new Date('2026-03')` invented the 1st. + * - Anything else (`15.03.2026`, `March 2026`, `Spring 2026`, an impossible + * `2026-02-30`) is shown as written instead of "Invalid Date" or a guessed day. + */ +const DAY = /^(\d{4})-(\d{2})-(\d{2})(?=$|[T\s])/; +const MONTH = /^(\d{4})-(\d{2})$/; + +export function formatJournalDate(raw: string | undefined, locale: string): string | null { + const value = raw?.trim(); + if (!value) return null; + + const day = DAY.exec(value); + if (day) { + const [y, m, d] = [Number(day[1]), Number(day[2]), Number(day[3])]; + const date = new Date(Date.UTC(y, m - 1, d)); + // Date.UTC rolls 2026-02-30 over into March; refuse instead of moving it. + if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) { + return value; + } + return date.toLocaleDateString(locale, { + timeZone: 'UTC', + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + + const month = MONTH.exec(value); + if (month) { + const [y, m] = [Number(month[1]), Number(month[2])]; + if (m < 1 || m > 12) return value; + return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(locale, { + timeZone: 'UTC', + year: 'numeric', + month: 'long', + }); + } + + return value; +} diff --git a/lib/mapService.ts b/lib/mapService.ts index dabad13..a94209b 100644 --- a/lib/mapService.ts +++ b/lib/mapService.ts @@ -1,5 +1,6 @@ import { immich } from './immich'; import { getConfig } from './config'; +import { isListedSubpage, listedAlbumIds } from './config/schema'; import { cache } from './cache'; /** @@ -44,13 +45,21 @@ export async function getMapData(): Promise { pendingMapDataPromise = (async () => { try { - const albums = await immich.getAlbums(); + // The allowlist still holds the albums of subpages taken offline with + // `enabled: false`, and those of `hidden` subpages, which are reachable + // by direct link only. A marker names, links and shows a photo of every + // album it counts — a listing — so both have to go before anything is + // aggregated. + const listed = listedAlbumIds(config); + const albums = (await immich.getAlbums()).filter((a) => listed.has(a.id)); // Build a lookup: album ID → { name, slug, subpageSlug? } const albumMeta = new Map(); for (const a of albums) { - // Check if this album belongs to a subpage - const sp = config.subpages.find((s) => s.albumIds.includes(a.id)); + // The subpage the marker links to, and whose password gates it. Never + // an offline one (its URL is a 404 and its password guards nothing), + // and never a hidden one: the marker would publish its address. + const sp = config.subpages.find((s) => isListedSubpage(s) && s.albumIds.includes(a.id)); albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug }); } diff --git a/lib/ogImage.ts b/lib/ogImage.ts new file mode 100644 index 0000000..77f8d16 --- /dev/null +++ b/lib/ogImage.ts @@ -0,0 +1,66 @@ +/** + * Signed URLs for the generated share card (`/api/og`). + * + * The card prints whatever title and subtitle its URL carries, on the site's + * own domain and in its colours. Unsigned, anyone could have the site render + * text of their choosing — a fake notice passed around as the site's own + * image — and every new string cost a fresh satori render. The pages that + * emit the URL sign the text they put in it; the route renders only text that + * carries a valid signature and falls back to the plain site card otherwise. + */ + +import crypto from 'crypto'; +import { getConfig } from './config'; + +export const OG_TITLE_MAX = 200; +export const OG_SUBTITLE_MAX = 100; + +/** Truncates by code point, so a cut never leaves half a surrogate pair. */ +function clip(text: string, max: number): string { + const chars = Array.from(text); + return chars.length > max ? chars.slice(0, max).join('') : text; +} + +function sign(secret: string, title: string, subtitle: string): string { + return crypto + .createHmac('sha256', secret) + .update(`og-card\0${title}\0${subtitle}`) + .digest('base64url') + .slice(0, 22); +} + +/** `/api/og` URL for a card showing `title` (and `subtitle`), signed. */ +export function ogImageUrl(title: string, subtitle = ''): string { + let secret: string; + try { + secret = getConfig().authSecret; + } catch { + // No secret to sign with — the route then shows the site title anyway. + return '/api/og'; + } + const t = clip(title, OG_TITLE_MAX); + const s = clip(subtitle, OG_SUBTITLE_MAX); + const params = new URLSearchParams({ title: t }); + if (s) params.set('subtitle', s); + params.set('sig', sign(secret, t, s)); + return `/api/og?${params.toString()}`; +} + +/** + * The title and subtitle of a card URL, or null unless the URL was produced + * by `ogImageUrl()` with this secret. + */ +export function verifiedOgText( + params: URLSearchParams, + secret: string, +): { title: string; subtitle: string } | null { + const title = params.get('title'); + const subtitle = params.get('subtitle') ?? ''; + const sig = params.get('sig'); + if (!title || !sig || !secret) return null; + + const expected = Buffer.from(sign(secret, title, subtitle)); + const given = Buffer.from(sig); + if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null; + return { title, subtitle }; +} diff --git a/lib/pages.ts b/lib/pages.ts new file mode 100644 index 0000000..982084b --- /dev/null +++ b/lib/pages.ts @@ -0,0 +1,140 @@ +/** + * Content pages (#722): timeless pages such as pricing, workshops or a booking + * FAQ, stored one per file in `content/pages/.md` and served at + * `/`. + * + * Client-safe (no `fs`) — the admin UI imports the slug rules from here. The + * file access lives in lib/admin/pages-service.ts. + * + * A page is block markdown in the journal format (lib/journal.ts), with a + * smaller frontmatter: `title`, `description` (SEO), `password`, `draft`. + * Whether it appears in the menu is not a property of the file: it is decided + * by a `- page: ` entry among the subpages in gallery.yaml, so the menu + * order lives in exactly one place. + */ + +import { isValidSlug, type JournalFrontmatter } from './journal'; + +export type PageFrontmatter = Pick< + JournalFrontmatter, + 'title' | 'description' | 'password' | 'draft' +>; + +export interface PageSummary { + slug: string; + frontmatter: PageFrontmatter; +} + +/** + * First path segments the app serves itself. A page slugged like one of them + * would never be reached — the fixed route wins — so it is refused outright. + * `proof` and `contact` are fixed routes too, beyond the list in the issue. + */ +export const RESERVED_PAGE_SLUGS: readonly string[] = [ + 'about', + 'map', + 'journal', + 'impressum', + 'privacy', + 'install', + 'admin', + 'gate', + 'api', + 'proof', + 'contact', +]; + +/** Everything a page slug must not collide with. */ +export interface SlugTakenBy { + subpages: readonly string[]; + albums: readonly string[]; + journal: readonly string[]; +} + +export type SlugCollision = + | { kind: 'invalid' } + | { kind: 'reserved' } + | { kind: 'subpage' } + | { kind: 'album' } + | { kind: 'journal' }; + +/** + * Why `slug` cannot be a page's URL, or null when it can. + * + * Journal entries live under /journal/ and cannot shadow a page, but they are + * checked all the same (#722): a page and an entry of the same name are two + * things a visitor would expect at one address. + */ +export function pageSlugCollision(slug: string, taken: SlugTakenBy): SlugCollision | null { + if (!isValidSlug(slug)) return { kind: 'invalid' }; + const wanted = slug.toLowerCase(); + if (RESERVED_PAGE_SLUGS.includes(wanted)) return { kind: 'reserved' }; + if (taken.subpages.some((s) => s.toLowerCase() === wanted)) return { kind: 'subpage' }; + if (taken.albums.some((s) => s.toLowerCase() === wanted)) return { kind: 'album' }; + if (taken.journal.some((s) => s.toLowerCase() === wanted)) return { kind: 'journal' }; + return null; +} + +/** A sentence for the admin UI and the doctor. */ +export function describeCollision(slug: string, collision: SlugCollision): string { + switch (collision.kind) { + case 'invalid': + return `"${slug}" is not a valid slug: use letters, digits, "-" and "_" only.`; + case 'reserved': + return `/${slug} is a built-in route of the site.`; + case 'subpage': + return `/${slug} is already a subpage.`; + case 'album': + return `/${slug} is already a standalone album.`; + case 'journal': + return `A journal entry already uses the slug "${slug}".`; + } +} + +/** One entry of the header menu, in gallery.yaml order. */ +export type NavEntry = { type: 'subpage'; slug: string } | { type: 'page'; slug: string }; + +/** The page slugs referenced from gallery.yaml, i.e. the pages shown in the menu. */ +export function menuPageSlugs(nav: readonly NavEntry[]): string[] { + return nav.filter((e) => e.type === 'page').map((e) => e.slug); +} + +export interface MenuItem { + key: string; + href: string; + label: string; +} + +/** + * The header menu's subpage and page links, in gallery.yaml order. + * + * `visibleSubpages` is what the menu may show (hidden and disabled subpages + * already dropped). A page reference whose file is missing, or that is still a + * draft, is left out the same way. A password-protected page stays listed, as + * a protected subpage does: the gate is at the page, not in the menu. + */ +export function resolveMenu( + nav: readonly NavEntry[], + visibleSubpages: ReadonlyArray<{ slug: string; name: string; title?: string }>, + pages: readonly PageSummary[], +): MenuItem[] { + const subpageBySlug = new Map(visibleSubpages.map((sp) => [sp.slug, sp])); + const pageBySlug = new Map(pages.map((p) => [p.slug, p])); + return nav.flatMap((entry): MenuItem[] => { + if (entry.type === 'subpage') { + const sp = subpageBySlug.get(entry.slug); + // The title a visitor sees on the page itself; `name` is the key the + // slug is made from, and only stands in when no title is set. + return sp ? [{ key: `sp-${sp.slug}`, href: `/${sp.slug}`, label: sp.title || sp.name }] : []; + } + const page = pageBySlug.get(entry.slug); + if (!page || page.frontmatter.draft) return []; + return [ + { + key: `page-${page.slug}`, + href: `/${page.slug}`, + label: page.frontmatter.title || page.slug, + }, + ]; + }); +} diff --git a/lib/password.ts b/lib/password.ts index 027f6e4..69bfdb4 100644 --- a/lib/password.ts +++ b/lib/password.ts @@ -49,6 +49,29 @@ export async function verifyScrypt(password: string, stored: string): Promise { + await verifyScrypt(password, DUMMY_HASH); +} + /** Produce a storable `scrypt:salt:hash_hex` string. */ export async function generateScryptHash(password: string): Promise { const salt = crypto.randomBytes(16).toString('hex'); diff --git a/lib/proofAccess.ts b/lib/proofAccess.ts new file mode 100644 index 0000000..6f34344 --- /dev/null +++ b/lib/proofAccess.ts @@ -0,0 +1,83 @@ +/** + * The shared preamble of every public proofing route: rate limit, site lock, + * the session behind the link token, and the album it was created for. + * + * The album ID always comes from the stored session, never from the request, + * which is what makes it safe to load an unpublished album here. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { immich, ImmichUnavailableError, type ImmichAsset } from './immich'; +import { checkRateLimit, getClientIp, retryAfterSeconds } from './rate-limit'; +import { siteLockResponse } from './auth'; +import { decodeAssetId } from './tokens'; +import { findSessionByToken, isExpired, type ProofSession } from './proofSessions'; + +export type ProofAccess = + { error: Response } | { session: ProofSession; albumName: string; assets: ImmichAsset[] }; + +const noStore = { 'Cache-Control': 'no-store' }; + +export function proofError(status: number, error: string, extra?: Record) { + return NextResponse.json({ error }, { status, headers: { ...noStore, ...extra } }); +} + +export async function resolveProofAccess( + request: NextRequest, + token: string, + bucket: string, + rpm: number, +): Promise { + const ip = getClientIp(request); + const rl = checkRateLimit(`${bucket}:${ip}`, rpm); + if (!rl.success) { + return { + error: proofError(429, 'Too many requests', { + 'Retry-After': String(retryAfterSeconds(rl.resetAt)), + }), + }; + } + + const locked = siteLockResponse(request); + if (locked) return { error: locked }; + + const session = await findSessionByToken(token); + // Unknown and expired answer the same, so a guessed token learns nothing. + if (!session || isExpired(session)) return { error: proofError(404, 'Not found') }; + + let album; + try { + album = await immich.getProofingAlbum(session.albumId); + } catch (error) { + if (error instanceof ImmichUnavailableError) { + return { error: proofError(503, 'Immich is currently unavailable', { 'Retry-After': '30' }) }; + } + throw error; + } + if (!album) return { error: proofError(404, 'Not found') }; + + return { + session, + albumName: album.albumName, + assets: album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO'), + }; +} + +/** + * Asset tokens from the client → Immich asset IDs of this album, in album + * order. Null when any token does not decode or names an asset outside the + * album: a token is unforgeable, but membership is what keeps one session + * from recording another album's photos. + */ +export function decodeSelection(tokens: unknown, assets: ImmichAsset[]): string[] | null { + if (!Array.isArray(tokens)) return null; + const inAlbum = new Set(assets.map((a) => a.id)); + const picked = new Set(); + for (const token of tokens) { + if (typeof token !== 'string') return null; + const id = decodeAssetId(token); + if (!id || !inAlbum.has(id)) return null; + picked.add(id); + } + return assets.filter((a) => picked.has(a.id)).map((a) => a.id); +} diff --git a/lib/proofExport.ts b/lib/proofExport.ts new file mode 100644 index 0000000..342625f --- /dev/null +++ b/lib/proofExport.ts @@ -0,0 +1,56 @@ +/** + * Turning a submitted selection into something the photographer can act on. + * + * The client picked from web previews; the photographer edits RAW files. So + * the most useful export is the list of file names *without* their extension: + * pasted into Lightroom's or Capture One's filename filter, it matches + * IMG_0412.CR3 as readily as IMG_0412.JPG. + */ + +export interface ExportAsset { + /** 1-based position in the album, as the client saw it. */ + position: number; + fileName: string; + takenAt?: string; +} + +export type ExportFormat = 'lightroom' | 'txt' | 'csv'; + +/** `IMG_0412.JPG` → `IMG_0412`. A name without a dot stays as it is. */ +export function baseName(fileName: string): string { + const dot = fileName.lastIndexOf('.'); + return dot > 0 ? fileName.slice(0, dot) : fileName; +} + +/** Quote a CSV field when it needs it (RFC 4180), and defuse spreadsheet formulas. */ +function csvField(value: string): string { + // A leading =, +, - or @ makes Excel and LibreOffice evaluate the cell. File + // names come from the client's camera, not from us — never let one run. + // Tab and CR start a formula in some spreadsheet apps as well (OWASP). + const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +export function formatExport(assets: ExportAsset[], format: ExportFormat): string { + switch (format) { + case 'lightroom': + // De-duplicated: a RAW+JPEG pair uploaded to Immich shares one base name. + return Array.from(new Set(assets.map((a) => baseName(a.fileName)))).join(', '); + case 'txt': + return assets.map((a) => a.fileName).join('\n') + (assets.length ? '\n' : ''); + case 'csv': { + const rows = [['position', 'file_name', 'taken_at']].concat( + assets.map((a) => [String(a.position), a.fileName, a.takenAt ?? '']), + ); + return rows.map((row) => row.map(csvField).join(',')).join('\r\n') + '\r\n'; + } + } +} + +export function exportContentType(format: ExportFormat): string { + return format === 'csv' ? 'text/csv; charset=utf-8' : 'text/plain; charset=utf-8'; +} + +export function exportExtension(format: ExportFormat): string { + return format === 'csv' ? 'csv' : 'txt'; +} diff --git a/lib/proofSessions.ts b/lib/proofSessions.ts new file mode 100644 index 0000000..7886c1f --- /dev/null +++ b/lib/proofSessions.ts @@ -0,0 +1,319 @@ +/** + * Client proofing sessions — "Proofing 2.0". + * + * The anonymous proofing on public albums keeps everything in the visitor's + * browser: favourites in localStorage, a bitmask in the URL, and a mailto the + * photographer may or may not receive. A proofing session is the handover + * version of that: the photographer creates a link for one client and one + * album, the client's picks are saved here as they make them, and a final + * "submit" locks the selection and tells the photographer. + * + * Storage is one JSON file, `content/proofing.json`, next to analytics.json — + * a portfolio has a handful of these at a time, not thousands. The file holds + * the link tokens, which are capabilities, so it is written 0600. + * + * The link token is the only credential a client has. It is 192 random bits, + * compared as an exact string lookup, and never derived from anything else. + * Admin routes address a session by its separate, non-secret `id`, so an admin + * URL in a log or a browser history does not leak a working client link. + * + * Selections are stored as Immich asset IDs. Clients only ever see the opaque + * asset tokens (lib/tokens.ts); the routes decode them and check that every + * one belongs to the session's album before anything is written. + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { atomicWrite } from './atomicWrite'; +import { isUuid } from './uuid'; + +/** What a session lets its client download as a ZIP of originals. */ +export type ProofDownload = 'none' | 'selection' | 'album'; + +export interface ProofSession { + /** Non-secret handle for the admin panel. */ + id: string; + /** The client link's capability: /proof/. */ + token: string; + /** Who the link is for, as the photographer wants to see it. */ + clientName: string; + albumId: string; + createdAt: string; + /** Last day the link works (YYYY-MM-DD, inclusive), or absent for no expiry. */ + expiresOn?: string; + download: ProofDownload; + /** How many ZIP downloads the link allows; absent means unlimited. */ + downloadLimit?: number; + downloadsUsed: number; + /** Selected Immich asset IDs, in the order the client picked them. */ + selection: string[]; + /** When the client last changed the selection. */ + updatedAt?: string; + /** Set once the client submits; the selection is read-only from then on. */ + submittedAt?: string; +} + +interface ProofStore { + sessions: ProofSession[]; +} + +/** The largest selection a session stores — far above any real proofing round. */ +export const MAX_SELECTION = 2000; + +/** Upper bound on sessions, so a runaway script cannot grow the file forever. */ +export const MAX_SESSIONS = 500; + +// ── Pure helpers ──────────────────────────────────────────────────── + +export type ProofState = 'open' | 'submitted' | 'expired'; + +/** + * Whether a session still works on `now`. `expiresOn` is inclusive: a link + * "valid until 12 May" still opens on the evening of 12 May, in the server's + * time zone. + */ +export function isExpired(session: Pick, now = new Date()): boolean { + if (!session.expiresOn) return false; + const end = new Date(`${session.expiresOn}T23:59:59.999`); + return Number.isNaN(end.getTime()) ? false : now.getTime() > end.getTime(); +} + +export function sessionState(session: ProofSession, now = new Date()): ProofState { + if (isExpired(session, now)) return 'expired'; + return session.submittedAt ? 'submitted' : 'open'; +} + +/** ZIP downloads left, or null for unlimited. */ +export function downloadsRemaining(session: ProofSession): number | null { + if (session.downloadLimit === undefined) return null; + return Math.max(0, session.downloadLimit - session.downloadsUsed); +} + +/** A 192-bit URL-safe token. */ +export function newToken(): string { + return crypto.randomBytes(24).toString('base64url'); +} + +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32}$/; + +/** Cheap shape check before any file is read for a token from a URL. */ +export function isTokenShaped(token: string): boolean { + return TOKEN_PATTERN.test(token); +} + +/** Input the admin panel may send when creating or editing a session. */ +export const sessionInputSchema = z.object({ + clientName: z.string().trim().min(1).max(120), + albumId: z.string().refine(isUuid, 'Not an Immich album ID'), + expiresOn: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .nullable() + .optional(), + download: z.enum(['none', 'selection', 'album']).default('none'), + downloadLimit: z.number().int().min(1).max(1000).nullable().optional(), +}); + +export type SessionInput = z.infer; + +export const sessionPatchSchema = sessionInputSchema + .omit({ albumId: true }) + .partial() + .extend({ + /** Unlock a submitted selection so the client can change it again. */ + reopen: z.literal(true).optional(), + /** Reset the download counter, e.g. after a failed download. */ + resetDownloads: z.literal(true).optional(), + }); + +export type SessionPatch = z.infer; + +// ── Storage ───────────────────────────────────────────────────────── + +function storeFile(): string { + return path.join(process.cwd(), 'content', 'proofing.json'); +} + +async function readStore(): Promise { + let raw: string; + try { + raw = await fs.readFile(storeFile(), 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { sessions: [] }; + throw err; + } + // A file that exists but does not parse is not treated as empty: the next + // write would replace every session with nothing. Let the caller fail. + const parsed = JSON.parse(raw) as Partial; + return { sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [] }; +} + +/** + * Serialises read-modify-write within this process, like the analytics store: + * the atomic rename keeps the file whole, but without the queue two concurrent + * saves would each write their own copy and one client's heart would vanish. + */ +let writeQueue: Promise = Promise.resolve(); + +function mutate(fn: (store: ProofStore) => T): Promise { + const run = writeQueue.then(async () => { + const store = await readStore(); + const result = fn(store); + await fs.mkdir(path.dirname(storeFile()), { recursive: true }); + await atomicWrite(storeFile(), JSON.stringify(store, null, 2), { mode: 0o600 }); + return result; + }); + // The queue must survive a failed write, or every later save would reject too. + writeQueue = run.catch(() => {}); + return run; +} + +export async function listSessions(): Promise { + return (await readStore()).sessions; +} + +export async function getSessionById(id: string): Promise { + return (await readStore()).sessions.find((s) => s.id === id) ?? null; +} + +/** + * Constant-time token comparison. With 192 random bits a timing attack is not + * practical, but the link token is the client's only credential and the + * comparison costs nothing to harden. + */ +function tokenMatches(stored: string, given: string): boolean { + const a = Buffer.from(stored); + const b = Buffer.from(given); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +export async function findSessionByToken(token: string): Promise { + if (!isTokenShaped(token)) return null; + return (await readStore()).sessions.find((s) => tokenMatches(s.token, token)) ?? null; +} + +export class ProofError extends Error { + constructor( + public readonly code: 'not-found' | 'expired' | 'submitted' | 'limit' | 'too-many' | 'empty', + message: string, + ) { + super(message); + } +} + +export async function createSession(input: SessionInput): Promise { + return mutate((store) => { + if (store.sessions.length >= MAX_SESSIONS) { + throw new ProofError('too-many', `At most ${MAX_SESSIONS} proofing links can exist.`); + } + const session: ProofSession = { + id: crypto.randomBytes(8).toString('hex'), + token: newToken(), + clientName: input.clientName, + albumId: input.albumId, + createdAt: new Date().toISOString(), + ...(input.expiresOn ? { expiresOn: input.expiresOn } : {}), + download: input.download, + ...(input.downloadLimit ? { downloadLimit: input.downloadLimit } : {}), + downloadsUsed: 0, + selection: [], + }; + store.sessions.push(session); + return session; + }); +} + +export async function updateSession(id: string, patch: SessionPatch): Promise { + return mutate((store) => { + const session = store.sessions.find((s) => s.id === id); + if (!session) throw new ProofError('not-found', 'No such proofing link.'); + + if (patch.clientName !== undefined) session.clientName = patch.clientName; + if (patch.download !== undefined) session.download = patch.download; + // `null` clears, `undefined` leaves alone. + if (patch.expiresOn === null) delete session.expiresOn; + else if (patch.expiresOn !== undefined) session.expiresOn = patch.expiresOn; + if (patch.downloadLimit === null) delete session.downloadLimit; + else if (patch.downloadLimit !== undefined) session.downloadLimit = patch.downloadLimit; + if (patch.reopen) delete session.submittedAt; + if (patch.resetDownloads) session.downloadsUsed = 0; + return session; + }); +} + +export async function deleteSession(id: string): Promise { + await mutate((store) => { + const before = store.sessions.length; + store.sessions = store.sessions.filter((s) => s.id !== id); + if (store.sessions.length === before) { + throw new ProofError('not-found', 'No such proofing link.'); + } + }); +} + +/** Find the session for a token and refuse anything that may not change. */ +function openSession(store: ProofStore, token: string): ProofSession { + const session = isTokenShaped(token) + ? store.sessions.find((s) => tokenMatches(s.token, token)) + : undefined; + if (!session) throw new ProofError('not-found', 'No such proofing link.'); + if (isExpired(session)) throw new ProofError('expired', 'This proofing link has expired.'); + return session; +} + +/** + * Replace the client's selection. The caller has already checked that every ID + * belongs to the session's album; this re-checks the session's own state inside + * the write lock, so a save racing a submit cannot slip in after it. + */ +export async function saveSelection(token: string, assetIds: string[]): Promise { + return mutate((store) => { + const session = openSession(store, token); + if (session.submittedAt) { + throw new ProofError('submitted', 'This selection has already been submitted.'); + } + session.selection = Array.from(new Set(assetIds)).slice(0, MAX_SELECTION); + session.updatedAt = new Date().toISOString(); + return session; + }); +} + +/** + * Lock the selection. Returns the session and whether this call was the one + * that submitted it, so a double click notifies the photographer only once. + */ +export async function submitSelection( + token: string, +): Promise<{ session: ProofSession; firstSubmit: boolean }> { + return mutate((store) => { + const session = openSession(store, token); + if (session.submittedAt) return { session, firstSubmit: false }; + // Checked here, under the write lock: a save of an empty selection racing + // the submit would otherwise lock nothing and still fire the webhook. + if (session.selection.length === 0) throw new ProofError('empty', 'Nothing selected.'); + session.submittedAt = new Date().toISOString(); + return { session, firstSubmit: true }; + }); +} + +/** Count one ZIP download against the link's limit, refusing past it. */ +export async function claimDownload( + token: string, + scope: 'selection' | 'album' = 'selection', +): Promise { + return mutate((store) => { + const session = openSession(store, token); + // Re-checked under the lock: the admin may have narrowed the link since + // the request read its snapshot. + if (session.download === 'none' || (scope === 'album' && session.download !== 'album')) { + throw new ProofError('not-found', 'This proofing link offers no downloads.'); + } + if (session.downloadLimit !== undefined && session.downloadsUsed >= session.downloadLimit) { + throw new ProofError('limit', 'The download limit for this link has been reached.'); + } + session.downloadsUsed += 1; + return session; + }); +} diff --git a/lib/proofWebhook.ts b/lib/proofWebhook.ts new file mode 100644 index 0000000..da804fd --- /dev/null +++ b/lib/proofWebhook.ts @@ -0,0 +1,105 @@ +/** + * Tell the photographer that a client submitted a proofing selection. + * + * One POST to `PROOFING_WEBHOOK_URL` with a JSON body shaped to be useful to + * as many receivers as possible without a per-service setting: Discord reads + * `content`, Slack and Mattermost read `text`, Gotify reads `title` and + * `message`, and n8n, Home Assistant or a script of your own get the + * structured `proofing` object. + * + * Fire-and-forget by design. A webhook that is down must never turn a client's + * submit into an error — the selection is already saved and visible in the + * admin panel — so failures are logged and swallowed, and the request is + * bounded by a short timeout. + */ + +import type { ProofSession } from './proofSessions'; + +const WEBHOOK_TIMEOUT_MS = 5000; + +export interface ProofWebhookPayload { + event: 'proofing.submitted'; + title: string; + message: string; + text: string; + content: string; + proofing: { + id: string; + clientName: string; + albumName: string; + selected: number; + submittedAt: string; + /** The admin page for this session, when a site URL is configured. */ + adminUrl: string | null; + }; +} + +export function buildWebhookPayload( + session: ProofSession, + albumName: string, + siteUrl: string | null, +): ProofWebhookPayload { + const count = session.selection.length; + const title = `Proofing: ${session.clientName} submitted a selection`; + const adminUrl = siteUrl ? `${siteUrl}/admin/proofing?session=${session.id}` : null; + const message = + `${session.clientName} picked ${count} ${count === 1 ? 'photo' : 'photos'} from "${albumName}".` + + (adminUrl ? ` ${adminUrl}` : ''); + return { + event: 'proofing.submitted', + title, + message, + text: `${title}\n${message}`, + content: `**${title}**\n${message}`, + proofing: { + id: session.id, + clientName: session.clientName, + albumName, + selected: count, + submittedAt: session.submittedAt ?? new Date().toISOString(), + adminUrl, + }, + }; +} + +/** Parse PROOFING_WEBHOOK_URL; anything that is not an http(s) URL is ignored. */ +export function webhookUrl(raw = process.env.PROOFING_WEBHOOK_URL): string | null { + const value = raw?.trim(); + if (!value) return null; + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null; + } catch { + return null; + } +} + +export async function notifySubmitted( + session: ProofSession, + albumName: string, + siteUrl: string | null, +): Promise { + const url = webhookUrl(); + if (!url) return; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildWebhookPayload(session, albumName, siteUrl)), + signal: controller.signal, + }); + if (!res.ok) { + console.warn(`[Proofing] Webhook answered ${res.status}; the selection is saved regardless.`); + } + } catch (err) { + console.warn( + '[Proofing] Webhook failed; the selection is saved regardless:', + err instanceof Error ? err.message : err, + ); + } finally { + clearTimeout(timer); + } +} diff --git a/lib/publicPages.ts b/lib/publicPages.ts index a9ecbd2..1fdce22 100644 --- a/lib/publicPages.ts +++ b/lib/publicPages.ts @@ -29,6 +29,15 @@ export interface PublicSubpage { export interface PublicJournalEntry { slug: string; draft: boolean; + /** The entry carries a password of its own. */ + isProtected: boolean; +} + +export interface PublicContentPage { + slug: string; + draft: boolean; + /** The page carries a password of its own. */ + isProtected: boolean; } export interface SiteShape { @@ -38,6 +47,8 @@ export interface SiteShape { /** Albums that are not part of any subpage. */ standaloneAlbums: PublicAlbum[]; journal: PublicJournalEntry[]; + /** Content pages (#722), in or out of the menu — both are public URLs. */ + pages?: PublicContentPage[]; /** Whether an album carries its own password. Keyed by Immich album id. */ isAlbumProtected: (albumId: string) => boolean; aboutEnabled: boolean; @@ -81,11 +92,19 @@ export function publicPaths(site: SiteShape): string[] { paths.push(`/${album.slug}`); } + for (const page of site.pages ?? []) { + // A page outside the menu is still a public page; a draft or a locked + // one is not, and its title would travel with the URL. + if (page.draft || page.isProtected) continue; + paths.push(`/${page.slug}`); + } + if (site.journalEnabled) { for (const entry of site.journal) { // Drafts stay visible to the logged-in admin and absent for everyone - // else; a listing is "everyone else". - if (entry.draft) continue; + // else; a listing is "everyone else". A locked entry is left out for + // the reason /journal hides it: its URL leads to a gate that names it. + if (entry.draft || entry.isProtected) continue; paths.push(`/journal/${entry.slug}`); } } diff --git a/lib/publishedAssets.ts b/lib/publishedAssets.ts new file mode 100644 index 0000000..5414959 --- /dev/null +++ b/lib/publishedAssets.ts @@ -0,0 +1,252 @@ +/** + * The set of Immich assets the public site currently shows somewhere. + * + * Asset tokens are deterministic, so a media URL that was ever served stays + * decodable. Without a membership check, /api/image, /api/video and /api/exif + * kept streaming an asset after its album was unpublished or a proofing link + * was revoked (GHSA-gfh4-6275-9gqv). The media routes now ask this module. + * + * What counts as published — every source that turns an asset ID into a + * public URL: + * - the assets and thumbnail of every standalone album and every album of + * an enabled subpage; a subpage taken offline with `enabled: false` keeps + * its albums on the allowlist, but publishes nothing + * - the homepage hero list, and the hero image of each album above + * - the about.md portrait + * - every journal entry: cover, photo blocks, and the assets of its album + * blocks (drafts and locked entries included: their page gate still hides + * them, and the tokens cannot be guessed) + * - every content page (#722): photo blocks and the assets of its album + * blocks, drafts and locked pages included for the same reason; its map + * blocks are not rendered, so their photos do not count + * - album blocks in an enabled subpage's inline essayText + * - the album of every open (unexpired) proofing link + * + * Built lazily, cached for CACHE_TTL, one build shared by concurrent callers. + * The admin routes run in their own module instances, so a save cannot reach + * this cache directly. Instead the set remembers the modification times of + * the content files it was built from and rebuilds as soon as one changes — + * so unpublishing an album or revoking a proofing link takes effect at once, + * not after the TTL. A miss also forces one rebuild before answering "no". + * + * "At once" means: every answer comes from a set whose build began after the + * request looked at the files. The files are stat'ed on every request (they + * used to be checked at most every 2 s, and a request in that window was + * answered — `immutable` — from the set before the save), and a build reads + * the stamp before the content, so a save racing a build leaves the set + * stamped older than the files instead of newer. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import yaml from 'js-yaml'; +import { getConfig } from './config'; +import { onlineAlbumIds } from './config/schema'; +import { immich } from './immich'; +import { collectAssetIds, parseJournalMarkdown, type JournalBlock } from './journal'; +import { listJournalEntries, readJournalEntry } from './admin/journal-service'; +import { listPages, readPage } from './admin/pages-service'; +import { isExpired, listSessions } from './proofSessions'; + +/** A forced rebuild on a miss at most this often, so misses cannot hammer Immich. */ +const MIN_REBUILD_MS = 10_000; + +const CONTENT = path.join(process.cwd(), 'content'); +const WATCHED = [ + 'gallery.yaml', + 'settings.yaml', + 'about.md', + 'proofing.json', + 'journal', + 'essays', + 'pages', +]; + +let current: { assets: Set; builtAt: number; stamp: string; startedAt: number } | null = + null; +let building: Promise> | null = null; +let lastForcedAt = 0; +/** + * Orders file checks and build starts. A set whose `startedAt` is above a + * request's check read the files after that request did, and so answers for + * it whatever the stamps say. + */ +let clock = 0; + +function albumBlockIds(blocks: readonly JournalBlock[]): string[] { + return blocks.flatMap((b) => (b.type === 'album' && b.albumId ? [b.albumId] : [])); +} + +async function aboutPortrait(): Promise { + try { + const raw = await fs.readFile(path.join(process.cwd(), 'content', 'about.md'), 'utf-8'); + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const meta = match ? (yaml.load(match[1]) as { portrait?: unknown } | null) : null; + return typeof meta?.portrait === 'string' ? meta.portrait : null; + } catch { + return null; + } +} + +/** Modification times of everything the set is built from. */ +async function contentStamp(): Promise { + const times = await Promise.all( + WATCHED.map((name) => + fs.stat(path.join(CONTENT, name)).then( + (st) => st.mtimeMs, + () => 0, + ), + ), + ); + return times.join(':'); +} + +async function build(): Promise> { + const config = getConfig(); + const assets = new Set(); + const add = (id: string | null | undefined) => { + if (id) assets.add(id); + }; + const rawAlbums = new Set(); + const online = onlineAlbumIds(config); + + // Published albums. A failing album must not empty the whole set. + await Promise.all( + [...online].map(async (id) => { + const album = await immich.getAlbum(id).catch(() => null); + if (!album) return; + add(album.albumThumbnailAssetId); + for (const a of album.assets) add(a.id); + }), + ); + + config.heroImages.forEach(add); + for (const [albumId, assetId] of Object.entries(config.albumHeroImages)) { + if (online.has(albumId)) add(assetId); + } + add(await aboutPortrait()); + + // Journal entries, including drafts and locked ones (see above). + const entries = await listJournalEntries().catch(() => []); + await Promise.all( + entries.map(async (entry) => { + const read = await readJournalEntry(entry.slug).catch(() => null); + if (!read) return; + add(read.parsed.frontmatter.coverAssetId); + collectAssetIds(read.parsed.blocks).forEach(add); + albumBlockIds(read.parsed.blocks).forEach((id) => rawAlbums.add(id)); + }), + ); + + // Content pages, drafts and locked ones included. Map blocks are dropped + // before a page renders, so their photos are not published through it. + const pages = await listPages().catch(() => []); + await Promise.all( + pages.map(async (page) => { + const read = await readPage(page.slug).catch(() => null); + if (!read) return; + const blocks = read.parsed.blocks.filter((b) => b.type !== 'map'); + collectAssetIds(blocks).forEach(add); + albumBlockIds(blocks).forEach((id) => rawAlbums.add(id)); + }), + ); + + // Inline essays in gallery.yaml: only their album blocks add assets; a photo + // block outside the subpage's albums is dropped before it becomes a URL. + for (const sp of config.subpages) { + if (sp.enabled !== false && sp.essayText) { + albumBlockIds(parseJournalMarkdown(sp.essayText).blocks).forEach((id) => rawAlbums.add(id)); + } + } + + // Open proofing links. Their albums are usually not published. + const sessions = await listSessions().catch(() => []); + const proofAlbums = new Set(sessions.filter((s) => !isExpired(s)).map((s) => s.albumId)); + await Promise.all( + [...proofAlbums].map(async (id) => { + const album = await immich.getProofingAlbum(id).catch(() => null); + for (const a of album?.assets ?? []) add(a.id); + }), + ); + + await Promise.all( + [...rawAlbums].map(async (id) => { + const list = await immich.getAlbumAssetsRaw(id).catch(() => []); + for (const a of list) add(a.id); + }), + ); + + return assets; +} + +function rebuild(): Promise> { + if (!building) { + const startedAt = ++clock; + // Stamp first, content second: a save landing mid-build then leaves the + // set stamped older than the files, and the next check rebuilds. Read in + // parallel, the stamp could come back newer than the content it labels. + building = contentStamp() + .then(async (stamp) => { + const assets = await build(); + current = { assets, builtAt: Date.now(), stamp, startedAt }; + return assets; + }) + .finally(() => { + building = null; + }); + } + return building; +} + +/** + * Whether the public site currently shows this asset anywhere. + * + * Fails open when the set cannot be built at all (Immich down on a cold + * start): refusing every photo would take the site down, and the tokens are + * unguessable either way. Once built, a stale set is kept through outages. + */ +export async function isPublishedAsset(assetId: string): Promise { + const ttlMs = getConfig().cacheTtl * 1000; + try { + const checkedAt = ++clock; + const stamp = await contentStamp(); + const stale = () => + !current || + Date.now() - current.builtAt > ttlMs || + (current.stamp !== stamp && current.startedAt < checkedAt); + // Twice at most: the first rebuild may join a build that started before + // this check; any build started after that one finished is recent enough. + for (let i = 0; i < 2 && stale(); i++) await rebuild(); + } catch { + if (!current) return true; + } + if (current!.assets.has(assetId)) return true; + + // Not in the set: it may have been published since the last build. A miss + // while a rebuild is running waits for it — the first view of a new album is + // a burst of misses, and refusing all but the one that started the rebuild + // broke most of its photos. Only with nothing running does the throttle + // decide whether a miss may start one. + if (!building) { + if (Date.now() - lastForcedAt < MIN_REBUILD_MS) return false; + lastForcedAt = Date.now(); + } + try { + return (await rebuild()).has(assetId); + } catch { + return false; + } +} + +/** Drop the cached set, e.g. after a save that publishes or unpublishes photos. */ +export function invalidatePublishedAssets(): void { + current = null; +} + +/** Test hook. */ +export function resetPublishedAssetsForTest(): void { + current = null; + building = null; + lastForcedAt = 0; + clock = 0; +} diff --git a/lib/requestBody.ts b/lib/requestBody.ts new file mode 100644 index 0000000..f2bbd43 --- /dev/null +++ b/lib/requestBody.ts @@ -0,0 +1,59 @@ +/** + * Request bodies read with a hard size cap. + * + * `request.json()` parses the whole body before anything can object, which is + * the one place an unbounded payload could land. These read the stream by + * hand and stop the moment the cap is passed, so a chunked body is capped too. + */ + +import type { NextRequest } from 'next/server'; + +/** + * Read a request body as raw bytes, aborting once it exceeds `limit` bytes. + * Null when the cap is passed. For callers that need the exact bytes on the + * wire, such as an HMAC over the body. + */ +export async function readBytesCapped(request: NextRequest, limit: number): Promise { + const stream = request.body; + if (!stream) return Buffer.alloc(0); + + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > limit) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + return Buffer.concat(chunks, bytes); + } finally { + reader.releaseLock(); + } +} + +/** Read a request body as text, aborting once it exceeds `limit` bytes. */ +export async function readBodyCapped(request: NextRequest, limit: number): Promise { + const bytes = await readBytesCapped(request, limit); + return bytes === null ? null : new TextDecoder().decode(bytes); +} + +/** + * Read a JSON body of at most `limit` bytes. Null for an oversized, empty or + * unparseable body — the caller answers 400 either way. + */ +export async function readJsonCapped(request: NextRequest, limit: number): Promise { + const declared = Number(request.headers.get('content-length') ?? ''); + if (Number.isFinite(declared) && declared > limit) return null; + try { + const text = await readBodyCapped(request, limit); + return text ? JSON.parse(text) : null; + } catch { + return null; + } +} diff --git a/lib/siteNav.server.ts b/lib/siteNav.server.ts new file mode 100644 index 0000000..712201c --- /dev/null +++ b/lib/siteNav.server.ts @@ -0,0 +1,38 @@ +/** + * Server-side loader for `siteNavLinks()` — fetches subpages, albums, pages + * and journal entries. Server only (pulls in `fs` via the services). + * + * Memoised per request with React's `cache()`: the root layout's header nav + * and the home page's hero nav both ask for it in one render, and the journal + * listing is shared with the journal pages (`lib/journal.server.ts`). + */ + +import { cache } from 'react'; +import { immich } from './immich'; +import { getConfig } from './config'; +import { listJournalEntriesForRequest } from './journal.server'; +import { listPages } from './admin/pages-service'; +import { resolveMenu } from './pages'; +import { getServerDictionary } from './i18n/server'; +import { siteNavLinks, type SiteNavLink } from './siteNav'; + +export const loadSiteNav = cache(async function loadSiteNav(): Promise { + const [subpages, albums, journalEntries, pages] = await Promise.all([ + immich.getSubpages(), + immich.getStandaloneAlbums(), + listJournalEntriesForRequest().catch(() => []), + listPages().catch(() => []), + ]); + const config = getConfig(); + return siteNavLinks( + { + menu: resolveMenu(config.nav, subpages, pages), + subpageAlbumCounts: new Map(subpages.map((sp) => [sp.slug, sp.albumCount])), + albums, + hasJournal: journalEntries.some((e) => !e.frontmatter.draft), + aboutEnabled: config.aboutEnabled, + map: config.map, + }, + getServerDictionary(), + ); +}); diff --git a/lib/siteNav.ts b/lib/siteNav.ts new file mode 100644 index 0000000..6d03858 --- /dev/null +++ b/lib/siteNav.ts @@ -0,0 +1,52 @@ +/** + * The site's navigation entries, in the one order the header and the home + * page hero share (#696): subpages and content pages (gallery.yaml order), + * standalone albums, Journal, About, Map. + * + * Client-safe: callers do the fetching and pass the results in. + */ + +import type { Dictionary } from './i18n'; +import type { MenuItem } from './pages'; + +export interface SiteNavLink { + key: string; + href: string; + label: string; + /** "3 albums" / "12 photos" — only the hero shows it, and only presets that want it. */ + count?: string; +} + +export interface SiteNavInput { + /** Subpages and content pages, from `resolveMenu()`. */ + menu: readonly MenuItem[]; + /** Album count per subpage slug, for the hero's count line. */ + subpageAlbumCounts?: ReadonlyMap; + albums: ReadonlyArray<{ id: string; slug: string; albumName: string; assetCount?: number }>; + /** At least one published (non-draft) journal entry. */ + hasJournal: boolean; + aboutEnabled: boolean; + map: boolean; +} + +export function siteNavLinks(input: SiteNavInput, t: Dictionary): SiteNavLink[] { + const links: SiteNavLink[] = input.menu.map((item) => { + const n = input.subpageAlbumCounts?.get(item.href.slice(1)); + return { + ...item, + ...(item.key.startsWith('sp-') && n !== undefined ? { count: t.common.albums(n) } : {}), + }; + }); + for (const a of input.albums) { + links.push({ + key: `al-${a.id}`, + href: `/${a.slug}`, + label: a.albumName, + ...(a.assetCount !== undefined ? { count: t.common.photos(a.assetCount) } : {}), + }); + } + if (input.hasJournal) links.push({ key: 'journal', href: '/journal', label: t.nav.journal }); + if (input.aboutEnabled) links.push({ key: 'about', href: '/about', label: t.nav.about }); + if (input.map) links.push({ key: 'map', href: '/map', label: t.nav.map }); + return links; +} diff --git a/lib/siteShape.ts b/lib/siteShape.ts index 285237c..d47fa8e 100644 --- a/lib/siteShape.ts +++ b/lib/siteShape.ts @@ -10,6 +10,7 @@ import { getConfig } from './config'; import { immich } from './immich'; import { isProtected, isSiteLocked } from './auth'; import { listJournalEntries } from './admin/journal-service'; +import { listPages } from './admin/pages-service'; import type { SiteShape, PublicAlbum } from './publicPages'; export async function buildSiteShape(): Promise { @@ -26,6 +27,7 @@ export async function buildSiteShape(): Promise { .map((album) => ({ id: album.id, slug: album.slug })); const journal = await listJournalEntries().catch(() => []); + const pages = await listPages().catch(() => []); return { siteLocked: isSiteLocked(), @@ -40,6 +42,12 @@ export async function buildSiteShape(): Promise { journal: journal.map((entry) => ({ slug: entry.slug, draft: entry.frontmatter.draft === true, + isProtected: !!entry.frontmatter.password, + })), + pages: pages.map((page) => ({ + slug: page.slug, + draft: page.frontmatter.draft === true, + isProtected: !!page.frontmatter.password, })), isAlbumProtected: (albumId) => isProtected(albumId, 'album'), aboutEnabled: config.aboutEnabled, diff --git a/lib/zipArchive.ts b/lib/zipArchive.ts new file mode 100644 index 0000000..604cde6 --- /dev/null +++ b/lib/zipArchive.ts @@ -0,0 +1,474 @@ +/** + * ZIP-of-originals streaming and the refusal page downloads answer with. + * + * Shared by the album archive route (/api/download/[album]/archive) and the + * client proofing download (/api/proof/[token]/archive), which authorise + * differently but must stream and refuse identically. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { Readable } from 'node:stream'; +import archiver from 'archiver'; +import { immich, type ImmichAsset } from '@/lib/immich'; +import { contentDisposition, safeDownloadName } from '@/lib/downloadName'; +import { getClientIp } from '@/lib/rate-limit'; +import { getDictionary } from '@/lib/i18n'; +import { getLocale, getServerDictionary } from '@/lib/i18n/server'; +import { getConfigOrNull } from '@/lib/config'; +import { accentForMode, resolveTheme } from '@/lib/config/theme'; +import { env } from '@/lib/env'; + +/** Escape a value interpolated into the refusal page. */ +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => + char === '&' + ? '&' + : char === '<' + ? '<' + : char === '>' + ? '>' + : char === '"' + ? '"' + : ''', + ); +} + +/** + * A refusal, rendered for whoever asked. + * + * A download begins as a navigation — the header link, or the proofing modal's + * form POST — and only a success carries `Content-Disposition: attachment`. A + * bare JSON body would therefore replace the gallery with + * `{"error":"Too many requests"}` on a rate limit, which is the likely failure + * (5 rpm, and the ZIP shows nothing until the first byte, so people click + * again). Browsers get a page with a way back; callers asking for JSON keep it. + * + * The page speaks the site's language; the JSON stays English, like every other + * API error. + */ +export function refusal( + request: NextRequest, + status: number, + reason: RefusalReason, + retryAfter?: number, +): NextResponse { + const headers: Record = { 'Cache-Control': 'no-store' }; + if (retryAfter) headers['Retry-After'] = String(retryAfter); + + if (!(request.headers.get('accept') ?? '').includes('text/html')) { + return NextResponse.json({ error: getDictionary('en').download[reason] }, { status, headers }); + } + + const t = getServerDictionary().download; + headers['Content-Type'] = 'text/html; charset=utf-8'; + // Route handlers are outside proxy.ts, so this page gets no site policy. It + // needs nothing but its own inline ` + + `

${escapeHtml(t.unavailableTitle)}

` + + `

${escapeHtml(t[reason])}

` + + `

${escapeHtml(t.back)}

` + + '
', + { status, headers }, + ); +} + +type Palette = { bg: string; text: string; muted: string }; + +/** + * Each preset's `--bg-primary`, `--text-primary` and `--text-secondary` per + * colour mode, as app/tokens.css defines them (`default` is its `:root` and + * `[data-theme='light']`, which `studio` uses unchanged). The stylesheets are + * bundled into hashed chunks a route handler cannot name, so the values are + * repeated here; lib/__tests__/refusal-palette.test.ts fails when they drift. + * The accent comes from the configured theme. + */ +export const REFUSAL_PALETTES: Record = { + default: { + dark: { bg: '#1a1a1a', text: '#f5f5f0', muted: '#a0a09a' }, + light: { bg: '#f5f5f0', text: '#1a1a18', muted: '#4a4a45' }, + }, + 'studio-modern': { + dark: { bg: '#121212', text: '#f4f4f2', muted: '#9c9c96' }, + light: { bg: '#fafaf8', text: '#161614', muted: '#5a5a55' }, + }, + minimal: { + dark: { bg: '#000000', text: '#ffffff', muted: '#9e9e9e' }, + light: { bg: '#ffffff', text: '#000000', muted: '#475569' }, + }, + editorial: { + dark: { bg: '#1a1714', text: '#ede8e0', muted: '#9a9086' }, + light: { bg: '#faf8f4', text: '#1a1714', muted: '#4a4540' }, + }, + classic: { + dark: { bg: '#121210', text: '#f0ece4', muted: '#a09888' }, + light: { bg: '#fdfbf5', text: '#1a1810', muted: '#4a4535' }, + }, + noir: { + dark: { bg: '#111014', text: '#f0ebe0', muted: '#998f80' }, + light: { bg: '#faf5ef', text: '#1a1610', muted: '#4a4538' }, + }, + monograph: { + dark: { bg: '#151515', text: '#e8e8e8', muted: '#a5a5a5' }, + light: { bg: '#fafafa', text: '#111111', muted: '#475569' }, + }, +}; + +const HEX_COLOUR = /^#[0-9a-f]{3,8}$/i; + +/** + * The refusal page's stylesheet, in the site's colour mode and accent, so a + * visitor bounced from a dark gallery does not land on a white system page. + * `auto` follows the visitor's OS, as the site does before its script runs. + */ +function refusalCss(): string { + const config = getConfigOrNull(); + const theme = config?.theme ?? resolveTheme(); + const mode = config?.colorMode ?? 'dark'; + const palette = REFUSAL_PALETTES[theme.preset] ?? REFUSAL_PALETTES.default; + const vars = (m: 'dark' | 'light') => { + const c = palette[m]; + const accent = accentForMode(theme, m); + return ( + `color-scheme:${m};--bg:${c.bg};--text:${c.text};--muted:${c.muted};` + + `--accent:${HEX_COLOUR.test(accent) ? accent : c.text}` + ); + }; + const base = + 'body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;' + + 'padding:0 1.5rem;box-sizing:border-box;background:var(--bg);color:var(--text);' + + 'font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.6}' + + 'main{max-width:32rem}h1{font-size:1.25rem;font-weight:500;margin:0 0 .75rem}' + + 'p{margin:0 0 1rem;color:var(--muted)}' + + 'a{color:var(--text);text-decoration-color:var(--accent);' + + 'text-decoration-thickness:2px;text-underline-offset:.25em}'; + const root = + mode === 'auto' + ? `:root{${vars('dark')}}@media (prefers-color-scheme: light){:root{${vars('light')}}}` + : `:root{${vars(mode)}}`; + return root + base; +} + +export type RefusalReason = 'notAvailable' | 'rateLimited' | 'immichUnavailable' | 'limitReached'; + +/** + * Where the refusal page's link leads: the page the download was started from, + * so a visitor lands back on the album rather than the home page. Only a + * same-origin `Referer` is followed, and only its path — anything else would + * turn this page into an open redirect with our name on it. + * + * "Same origin" is judged by the host the visitor addressed. It used to be + * `request.nextUrl.origin`, which Next rebuilds from its own bind address — + * `localhost` behind `-H 127.0.0.1` or a reverse proxy — so no real Referer + * ever matched and the link always led home. + */ +function backHref(request: NextRequest): string { + const referer = request.headers.get('referer'); + if (!referer) return '/'; + try { + const url = new URL(referer); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return '/'; + if (!requestHosts(request).has(url.host.toLowerCase())) return '/'; + // `//evil.example/x` is a path to URL, but a host to the browser. + if (url.pathname.startsWith('//')) return '/'; + return `${url.pathname}${url.search}`; + } catch { + return '/'; + } +} + +/** + * The hosts this request may have been addressed to: the `Host` header and, + * only with `TRUSTED_PROXY_HOPS` set (the same trust lib/rate-limit.ts needs), + * the proxy's `X-Forwarded-Host`. A forged header can at most make a Referer + * match; the link that results is still a path on this site. + */ +function requestHosts(request: NextRequest): Set { + const hosts = new Set([request.nextUrl.host.toLowerCase()]); + const host = request.headers.get('host'); + if (host) hosts.add(host.trim().toLowerCase()); + if (env.TRUSTED_PROXY_HOPS > 0) { + for (const h of (request.headers.get('x-forwarded-host') ?? '').split(',')) { + if (h.trim()) hosts.add(h.trim().toLowerCase()); + } + } + return hosts; +} + +/** + * A unique, ZIP-safe entry name. Originals can collide ("IMG_0001.jpg" from two + * cards), and a ZIP with duplicate entry names is ambiguous to unzip. + */ +function uniqueEntryName(raw: string | undefined, used: Set): string { + const base = safeDownloadName(raw); + let name = base; + let n = 2; + while (used.has(name)) { + const dot = base.lastIndexOf('.'); + name = dot > 0 ? `${base.slice(0, dot)}-${n}${base.slice(dot)}` : `${base}-${n}`; + n++; + } + used.add(name); + return name; +} + +/** + * Append one entry, resolving once it has been written (or the archive has been + * torn down because the visitor left). + * + * archiver processes entries strictly one at a time — that is what keeps memory + * flat — but `append()` only *queues* them. Firing every `append` up front (and + * so opening every upstream request at once) is what made a large album's later + * originals sit long enough to time out and truncate the ZIP. Awaiting the + * `entry` event holds the loop until the previous file has finished, so only + * one original is ever in flight. `close` resolves too, so an aborted download + * stops the loop rather than hanging on a file nothing will read. + * + * On `close` and `error` the source is destroyed as well. archiver does not do + * that for a queued or half-read entry, and the upstream body would otherwise + * hold its socket out of undici's pool until GC finalises it (#635). + */ +function appendEntry(archive: archiver.Archiver, source: Readable, name: string): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + archive.off('entry', onEntry); + archive.off('error', onError); + archive.off('close', onClose); + }; + const onEntry = () => { + cleanup(); + resolve(); + }; + // `close` fires when the response is consumed or aborted. Resolving on it + // lets the loop see `archive.destroyed` and stop, rather than hanging on a + // file nothing will read. + const onClose = () => { + cleanup(); + source.destroy(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + source.destroy(); + reject(error); + }; + archive.once('entry', onEntry); + archive.once('error', onError); + archive.once('close', onClose); + archive.append(source, { name }); + }); +} + +/** + * Archives one client may have open at the same time, the album archive and + * the proofing archive counted together. + * + * The 5-per-minute rate limit bounds how often an archive starts, not how long + * it lives. Each open archive holds an Immich socket and a few MB of buffers + * for as long as the client keeps its connection, so a client that reads + * slowly could otherwise stack them up minute after minute. Two leaves room + * for the proofing modal's selection and album ZIPs side by side. + */ +export const MAX_CONCURRENT_ARCHIVES = 2; + +/** `Retry-After` for a refusal at the concurrency cap. */ +const ARCHIVE_SLOT_RETRY_AFTER = 30; + +/** + * How long an archive waits for the client to take the next bytes before it + * is torn down, upstream Immich stream included. + * + * Counted from the last pull, and only while data is waiting for the client: a + * slow download that keeps reading never trips it, and neither does a slow + * Immich (the pull is then still pending, and the upstream request has its own + * timeouts). It is needed because backpressure pauses undici's body timeout, + * so a client that stopped reading kept the archive and its Immich socket for + * as long as it held the TCP connection open. + */ +export const ARCHIVE_STALL_TIMEOUT_MS = 60_000; + +/** Open archives per client IP. An entry is removed when it drops to 0. */ +const inFlight = new Map(); + +/** + * Take one of the client's archive slots. Returns the release function (safe + * to call more than once), or `null` when the client is at the cap. + */ +function acquireArchiveSlot(ip: string): (() => void) | null { + const current = inFlight.get(ip) ?? 0; + if (current >= MAX_CONCURRENT_ARCHIVES) return null; + inFlight.set(ip, current + 1); + let released = false; + return () => { + if (released) return; + released = true; + const remaining = (inFlight.get(ip) ?? 1) - 1; + if (remaining > 0) inFlight.set(ip, remaining); + else inFlight.delete(ip); + }; +} + +/** Test seam: archives currently holding a slot for `ip`. */ +export function __archivesInFlight(ip: string): number { + return inFlight.get(ip) ?? 0; +} + +export type ArchiveStreamer = (albumName: string, assets: ImmichAsset[]) => Response; + +/** + * Run an archive route's handler inside one of the client's archive slots. + * + * The slot is taken before the handler runs, so before any Immich request, and + * a client that already has `MAX_CONCURRENT_ARCHIVES` open gets a 429. The + * handler receives a `stream` function to use instead of `streamArchive`: an + * archive started through it owns the slot and gives it back when it ends, + * fails, is cancelled or stalls. If the handler answers any other way (a + * refusal, a throw), the slot is given back here. + * + * The client key is `getClientIp()`, so it honours `TRUSTED_PROXY_HOPS` like + * the rate limiter. + */ +export async function withArchiveSlot( + request: NextRequest, + handler: (stream: ArchiveStreamer) => Promise, +): Promise { + const release = acquireArchiveSlot(getClientIp(request)); + if (!release) return refusal(request, 429, 'rateLimited', ARCHIVE_SLOT_RETRY_AFTER); + + let handedOver = false; + try { + return await handler((albumName, assets) => { + const response = streamArchive(albumName, assets, release); + handedOver = true; + return response; + }); + } finally { + if (!handedOver) release(); + } +} + +/** + * Stream `assets` as a ZIP of originals. + * + * archiver writes data descriptors, so entry sizes are never known up front and + * memory stays flat no matter how large the album is. The loop pulls one + * original at a time and stops as soon as the response is gone. + * + * `onDone` runs exactly once, when the archive has been read to the end, + * failed, been cancelled or stalled; `withArchiveSlot` hands its slot release + * in here. + */ +export function streamArchive( + albumName: string, + assets: ImmichAsset[], + onDone: () => void = () => {}, +): NextResponse { + const archive = archiver('zip', { store: true }); + archive.on('error', (err) => { + // A visitor cancelling the download is not a failure worth a log line. + if (err.name === 'AbortError') return; + console.error(`[Download] Archive stream failed:`, err); + }); + + let stallTimer: ReturnType | undefined; + let done = false; + const finish = () => { + clearTimeout(stallTimer); + if (done) return; + done = true; + onDone(); + }; + // Every way an archive ends passes through `close`: read to the end, + // destroyed by an error, by a cancel, or by the stall timer below. + archive.once('close', finish); + + // A pull-based body rather than `Readable.toWeb()`: `pull` runs only once + // the client has taken what was queued, which is the signal the stall timer + // needs. The queue holds a single chunk; archiver's own buffer and + // backpressure do the rest, as before. + const chunks = archive[Symbol.asyncIterator]() as AsyncIterator; + const body = new ReadableStream( + { + async pull(controller) { + clearTimeout(stallTimer); + try { + const next = await chunks.next(); + if (next.done) { + controller.close(); + finish(); + return; + } + // A Buffer is a Uint8Array: handed on as is, not copied. + controller.enqueue(next.value); + } catch (err) { + // Also reached when a cancel won the race: erroring a cancelled + // stream is a no-op. + controller.error(err); + finish(); + return; + } + if (done) return; + // The chunk is queued; the next move is the client's. + stallTimer = setTimeout(() => { + console.warn( + `[Download] Archive closed: nothing read for ${ARCHIVE_STALL_TIMEOUT_MS / 1000}s`, + ); + controller.error(new Error('Archive download stalled')); + // Destroys the entry in flight and with it the upstream body + // (appendEntry), and stops the fill loop. + archive.destroy(); + finish(); + }, ARCHIVE_STALL_TIMEOUT_MS); + stallTimer.unref?.(); + }, + cancel() { + // The visitor left: tear the archive down, upstream body included. + archive.destroy(); + finish(); + }, + }, + { highWaterMark: 1 }, + ); + + // Fill the archive in the background: the response has to go out first so the + // browser starts reading, and each originals fetch is awaited in turn. + void (async () => { + try { + const used = new Set(); + for (const asset of assets) { + // The visitor left (or the archive failed): stop pulling originals. + if (archive.destroyed) break; + const result = await immich.streamAsset(asset.id, 'original'); + if (!result) continue; + if (archive.destroyed) { + // Left while the headers were on their way: release the body unread. + await result.stream.cancel(); + break; + } + const nodeStream = Readable.fromWeb( + result.stream as unknown as import('node:stream/web').ReadableStream, + ); + await appendEntry(archive, nodeStream, uniqueEntryName(asset.originalFileName, used)); + } + if (!archive.destroyed) await archive.finalize(); + } catch (err) { + archive.destroy(err instanceof Error ? err : new Error(String(err))); + } + })(); + + const zipName = `${safeDownloadName(albumName, 'album')}.zip`; + return new NextResponse(body, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': contentDisposition(zipName), + // Private: authorised per visitor, so a shared cache must not hand the + // archive to the next one. + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} diff --git a/package-lock.json b/package-lock.json index 285867a..1f37727 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-folio", - "version": "0.18.1", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-folio", - "version": "0.18.1", + "version": "0.19.0", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index fc8c5de..194bf32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "immich-folio", - "version": "0.18.1", + "version": "0.19.0", "license": "MIT", "scripts": { "dev": "next dev", diff --git a/proxy.ts b/proxy.ts index fc5009b..d0c7ab6 100644 --- a/proxy.ts +++ b/proxy.ts @@ -62,6 +62,26 @@ function siteGate(request: NextRequest): NextResponse | null { return NextResponse.rewrite(new URL(GATE_PATH, request.url)); } +/** + * The first segments that belong to a fixed route under app/ rather than to + * the app/[...path] catch-all. A deep path below one of these is either served + * by that route or answered 404 by Next itself, so isKnownMissing() leaves it + * alone. lib/__tests__/proxy.test.ts holds this list to the app/ directory. + */ +export const TOP_LEVEL_ROUTES: ReadonlySet = new Set([ + 'about', + 'admin', + 'api', + 'contact', + 'gate', + 'impressum', + 'install', + 'journal', + 'map', + 'privacy', + 'proof', +]); + /** * Pages that are known not to exist before anything renders. * @@ -71,6 +91,10 @@ function siteGate(request: NextRequest): NextResponse | null { * For the fixed routes whose existence depends only on settings.yaml or a file * in content/journal/, the answer is known here, before streaming starts, so * they can get a real 404. Album and subpage slugs need Immich and stay soft. + * + * The same holds for any path deeper than two segments that lands in the + * catch-all: app/[...path] only serves `/` and `//`, and + * calls notFound() for everything longer without asking Immich. */ export function isKnownMissing( pathname: string, @@ -86,7 +110,14 @@ export function isKnownMissing( const journal = /^\/journal\/([^/]+)\/?$/.exec(pathname); if (journal) { - const slug = decodeURIComponent(journal[1]); + let slug: string; + try { + slug = decodeURIComponent(journal[1]); + } catch { + // A malformed escape such as /journal/%E0 names no entry, and must not + // throw out of the proxy. + return true; + } if (!isValidSlug(slug)) return true; // A draft still has its file, so it stays a soft 404 for visitors and // reachable for a signed-in admin, which only the page can tell apart. @@ -94,6 +125,15 @@ export function isKnownMissing( fs.existsSync(path.join(contentDir, dir, `${slug}.md`)), ); } + + const segments = pathname.split('/').filter(Boolean); + if (segments.length > 2) { + const [first] = segments; + // Next's own paths (/_next/…, /__nextjs_…) and dotfiles such as + // /.well-known/… are not the catch-all's to answer. + if (first.startsWith('_') || first.startsWith('.')) return false; + return !TOP_LEVEL_ROUTES.has(first); + } return false; } @@ -155,18 +195,19 @@ export function proxy(request: NextRequest) { requestHeaders.set('x-pathname', request.nextUrl.pathname); requestHeaders.set('Content-Security-Policy', cspDirectives); - // The page itself still renders and calls notFound(); this only lets the + // The page itself still renders and calls notFound(); the 404 only lets the // status say so before streaming starts. See isKnownMissing(). - const response = isKnownMissing(request.nextUrl.pathname) - ? NextResponse.rewrite(request.nextUrl, { - request: { headers: requestHeaders }, - status: 404, - }) - : NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); + // + // This is NextResponse.next() carrying a status, not a rewrite to the same + // URL. A rewrite destination whose origin differs from the one the server + // resolved the request against is proxied as an external URL, and + // request.nextUrl reports 127.0.0.1 and ::1 as "localhost". Behind + // `next start -H 127.0.0.1` every such rewrite went out to localhost, which + // resolved to ::1 where nothing listens: a 30 s hang, then a 500. + const response = NextResponse.next({ + request: { headers: requestHeaders }, + ...(isKnownMissing(request.nextUrl.pathname) ? { status: 404 } : {}), + }); // Only the CSP is set here. Every other security header comes from // next.config.ts, which also covers /api and static assets. Setting a header diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 0a68fab..e051c4a 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -477,20 +477,25 @@ async function captureAdmin(browser: Browser): Promise { await page.locator('.admin-login-card').waitFor({ timeout: 15000 }); await capture(page, 'admin-login'); + // Login lands on the overview (#712). It is not captured: its "needs + // attention" list is the live doctor, which names albums, IDs and secrets' + // lengths — nothing for a public screenshot. await adminLogin(page); + + // The page builder is a structure list and a panel (#717): open the first + // subpage, so the panel shows a page with its albums rather than the + // empty state. + await page.goto(`${BASE_URL}/admin/pages`, { waitUntil: 'networkidle' }); + await page.locator('.pb-tree').waitFor({ timeout: 20000 }); + const firstSubpage = page.locator('.pb-tree .pb-group').nth(1).locator('.pb-row-main').first(); + if ((await firstSubpage.count()) > 0) await firstSubpage.click(); await page.waitForTimeout(2500); await waitForImages(page); await capture(page, 'admin-page-builder'); await captureAlbumPicker(page); - // Reload rather than trusting Escape to have closed the picker — a modal - // still on screen swallows the click on the settings tab. - await page.reload({ waitUntil: 'networkidle' }); - await page.locator('.admin-tabs').waitFor({ timeout: 20000 }); - await page.locator('.admin-tab', { hasText: 'Settings' }).first().click(); - await page.waitForTimeout(1500); - await capture(page, 'admin-settings'); + await capturePage(page, '/admin/settings/general', 'admin-settings', 1500); }); } @@ -634,7 +639,7 @@ async function adminLogin(page: Page): Promise { await page.goto(`${BASE_URL}/admin`, { waitUntil: 'networkidle' }); await page.locator('.admin-login-card input[type="password"]').fill(ADMIN_PASSWORD!); await page.locator('.admin-login-card button[type="submit"]').click(); - await page.locator('.admin-tabs').waitFor({ timeout: 20000 }); + await page.locator('.admin-sidebar').waitFor({ timeout: 20000 }); } /** @@ -853,6 +858,12 @@ see the frames on a proper screen before deciding what this is about. /** Best effort: the picker is a modal behind a button whose label may change. */ async function captureAlbumPicker(page: Page): Promise { + // "Add album" lives in the standalone-albums panel of the page builder. + const standalone = page.locator('.pb-row-solo').last(); + if ((await standalone.count()) > 0) { + await standalone.click(); + await page.waitForTimeout(800); + } const trigger = page.getByRole('button', { name: /add album/i }).first(); if ((await trigger.count()) === 0) { console.log(' ⏭ No "Add album" button found — skipping the album picker shot.'); diff --git a/vitest.config.ts b/vitest.config.ts index ca57dde..f1d7d63 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ // `// @vitest-environment jsdom` in its first docblock (see // components/__tests__ for examples), which keeps the fast path fast. environment: 'node', - setupFiles: ['./vitest.setup.ts'], + setupFiles: ['./vitest.setup.ts', './vitest.content-guard.ts'], include: [ 'lib/__tests__/**/*.test.{ts,tsx}', 'app/**/__tests__/**/*.test.{ts,tsx}', diff --git a/vitest.content-guard.ts b/vitest.content-guard.ts new file mode 100644 index 0000000..71a5faf --- /dev/null +++ b/vitest.content-guard.ts @@ -0,0 +1,165 @@ +/** + * Refuses every filesystem write into the checkout's own content/ directory + * while the unit suite runs. + * + * In a self-hosted deployment that directory is the live content — the + * container mounts it — so a test that writes a journal entry there publishes + * it for as long as the test runs and leaves a `.deleted.bak` behind in + * content/journal/.backups. Tests point `process.cwd()` at a temp directory + * instead (see journal-backup.test.ts); this guard makes forgetting that fail + * loudly rather than quietly touching the site. + * + * Why patch fs rather than snapshot the directory before and after: the live + * site writes analytics.json and contact messages into the same directory at + * any moment, which a snapshot cannot tell apart from a test. Patching sees + * only this process's writes, blocks them before they happen, and catches a + * file that is written and deleted again within one test. + * + * A blocked write throws, and is also recorded, because service code often + * swallows errors (`catch {}` in cleanup hooks, `.catch(() => {})` after a + * failed rename); the afterEach below turns any record into a test failure. + */ +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, afterEach } from 'vitest'; + +type AnyFn = (...args: unknown[]) => unknown; + +interface GuardState { + contentDir: string; + violations: string[]; +} + +const STATE_KEY = Symbol.for('folio.contentWriteGuard'); + +// Tests spy on process.cwd(); the bound original keeps resolving relative +// paths the way the OS does, against the real working directory. +const realCwd = process.cwd.bind(process); + +function toPath(p: unknown): string | null { + if (typeof p === 'string') return p; + if (Buffer.isBuffer(p)) return p.toString(); + if (p instanceof URL) return p.protocol === 'file:' ? fileURLToPath(p) : null; + return null; // a file descriptor or FileHandle: nothing to resolve +} + +/** Flags that can create, truncate or change a file. */ +function isWriteFlag(flags: unknown): boolean { + if (flags === undefined || flags === null) return false; + if (typeof flags === 'number') { + const { O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND } = fs.constants; + return (flags & (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC | O_APPEND)) !== 0; + } + return typeof flags === 'string' && /[wa+]/.test(flags); +} + +function install(): GuardState { + const existing = (fs as unknown as Record)[STATE_KEY]; + if (existing) return existing; + + const state: GuardState = { + contentDir: path.join(path.dirname(fileURLToPath(import.meta.url)), 'content'), + violations: [], + }; + + const check = (op: string, p: unknown) => { + const raw = toPath(p); + if (raw === null) return; + const resolved = path.resolve(realCwd(), raw); + if (resolved !== state.contentDir && !resolved.startsWith(state.contentDir + path.sep)) { + return; + } + const message = + `Test tried to ${op} ${resolved} — the real content/ directory. ` + + `Point process.cwd() at a temp directory first (see journal-backup.test.ts).`; + state.violations.push(message); + throw new Error(message); + }; + + // Which arguments are destination paths. rename moves the source too, so + // both of its paths count; copies and links only create their second. + const targets: Record = { + writeFile: [0], + appendFile: [0], + mkdir: [0], + mkdtemp: [0], + rm: [0], + rmdir: [0], + unlink: [0], + truncate: [0], + chmod: [0], + chown: [0], + lchown: [0], + utimes: [0], + lutimes: [0], + rename: [0, 1], + copyFile: [1], + cp: [1], + link: [1], + symlink: [1], + }; + + // A promise-returning fs function never throws synchronously, so the + // fs.promises wrappers reject instead — a `.catch()` on the call must see it. + const wrap = ( + owner: Record, + name: string, + guard: (args: unknown[]) => void, + rejects = false, + ) => { + const original = owner[name]; + if (typeof original !== 'function') return; + owner[name] = function guarded(this: unknown, ...args: unknown[]) { + try { + guard(args); + } catch (err) { + if (rejects) return Promise.reject(err); + throw err; + } + return (original as AnyFn).apply(this, args); + }; + }; + + const fsObj = fs as unknown as Record; + const promises = fs.promises as unknown as Record; + for (const [name, indices] of Object.entries(targets)) { + const guard = (args: unknown[]) => indices.forEach((i) => check(name, args[i])); + wrap(fsObj, name, guard); + wrap(fsObj, `${name}Sync`, guard); + wrap(promises, name, guard, true); + } + const openGuard = (args: unknown[]) => { + if (isWriteFlag(args[1])) check('open for writing', args[0]); + }; + wrap(fsObj, 'open', openGuard); + wrap(fsObj, 'openSync', openGuard); + wrap(promises, 'open', openGuard, true); + wrap(fsObj, 'createWriteStream', (args) => check('stream into', args[0])); + + // `import { writeFile } from 'fs/promises'` binds the export once; this + // pushes the wrapped functions into those bindings too. + syncBuiltinESMExports(); + + Object.defineProperty(fs, STATE_KEY, { value: state }); + return state; +} + +const state = install(); + +/** Returns and clears the recorded violations. For the guard's own test. */ +export function takeContentWriteViolations(): string[] { + return state.violations.splice(0); +} + +/** The directory the guard protects. For the guard's own test. */ +export const guardedContentDir = state.contentDir; + +function failOnViolations() { + const found = takeContentWriteViolations(); + if (found.length > 0) throw new Error(found.join('\n')); +} + +afterEach(failOnViolations); +afterAll(failOnViolations);