release: v0.16.0
See CHANGELOG.md for the full v0.16.0 entry. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
318
CHANGELOG.md
318
CHANGELOG.md
@@ -7,6 +7,324 @@ 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.16.0] — 2026-09-22
|
||||
|
||||
### Security
|
||||
|
||||
- **The image proxy no longer serves un-downsampled originals**
|
||||
([#653](https://github.com/ralksta/immich-folio/pull/653),
|
||||
[GHSA-36m4-p39x-9wx8](https://github.com/ralksta/immich-folio/security/advisories/GHSA-36m4-p39x-9wx8)).
|
||||
`resolveImageSize()` could resolve to the original tier from a single
|
||||
request parameter. `/api/image` treats the opaque asset token as its whole
|
||||
capability check — holding one means you saw the page it was rendered on,
|
||||
which is the right bar for a preview but not for the un-downsampled file.
|
||||
`resolveImageSize()` now caps its result at `preview` regardless of what
|
||||
`?size=` or `?w=` ask for; a small width still narrows to `thumbnail` as
|
||||
before. Originals remain available only through `/api/download`, which
|
||||
verifies the album allowlist, the album's `download: true` opt-in, and
|
||||
every password gate on the way. **See the upgrade notes: this needs an
|
||||
`IMAGE_CACHE_VERSION` bump to reach browsers that already cached an
|
||||
original under the old response.**
|
||||
|
||||
- **Password-protected journal entries and albums no longer leak outside
|
||||
their own gate**
|
||||
([#654](https://github.com/ralksta/immich-folio/pull/654),
|
||||
[GHSA-fvgv-97g3-wjr7](https://github.com/ralksta/immich-folio/security/advisories/GHSA-fvgv-97g3-wjr7)).
|
||||
A journal entry's `password:` only ever gated the body of
|
||||
`/journal/<slug>`. Three other surfaces handed out its content regardless,
|
||||
and the same class of bug reached album and subpage metadata: the public
|
||||
journal index listed a protected entry's title, subtitle, cover image and
|
||||
excerpt to everyone; `generateMetadata` for a journal entry, album or
|
||||
subpage ran ahead of the page's own gate, so a locked page's real title,
|
||||
photo count and cover image reached `<head>` of a 200 response nobody had
|
||||
authenticated for — including a shared `?photo=` link's Open Graph image,
|
||||
which named the exact photo; and the journal detail page spread an entry's
|
||||
whole frontmatter into the essay renderer's props, so the stored password
|
||||
(plaintext or a scrypt hash) rode along in the RSC payload of anyone who
|
||||
had unlocked the entry once. All three now run the same
|
||||
`isProtected`/`isAuthenticated` checks the page body already used.
|
||||
|
||||
- **Every route to an album now enforces its subpage's and its own
|
||||
password**
|
||||
([#638](https://github.com/ralksta/immich-folio/pull/638),
|
||||
[GHSA-g85g-xcqc-f59h](https://github.com/ralksta/immich-folio/security/advisories/GHSA-g85g-xcqc-f59h),
|
||||
[GHSA-jhmx-5j9c-qg42](https://github.com/ralksta/immich-folio/security/advisories/GHSA-jhmx-5j9c-qg42)).
|
||||
Two related fixes from the v0.16.0 audit. `getAlbumBySlug()` now searches
|
||||
only the albums reachable from the route the slug arrived on — standalone
|
||||
albums for a top-level slug, a subpage's own albums otherwise, nothing for
|
||||
a disabled subpage — so an album listed only inside a subpage no longer
|
||||
answers at the bare `/<album-slug>`, bypassing that subpage's gate.
|
||||
Separately, every exclusion in the proxy's route matcher now ends on a
|
||||
segment boundary, closing a gap where a path merely _starting with_ an
|
||||
excluded name skipped the site password and CSP entirely.
|
||||
|
||||
- **Hardening: cache-bypass, analytics, rate limiting, file permissions**
|
||||
([#655](https://github.com/ralksta/immich-folio/pull/655),
|
||||
[GHSA-w293-x8pc-j4cv](https://github.com/ralksta/immich-folio/security/advisories/GHSA-w293-x8pc-j4cv)).
|
||||
Four lower-severity findings from the same audit:
|
||||
- The unauthenticated `?fresh=1`/`?preview=true` cache-bypass flags are now
|
||||
gated to `isAdminAuthenticated()`, and a forced refresh no longer evicts
|
||||
the existing cache entry before the refetch succeeds — a request timed
|
||||
during an Immich outage could otherwise wipe out the stale fallback that
|
||||
was keeping the page up for everyone else.
|
||||
- `POST /api/analytics/track` gained a rate limit (60 rpm per IP), a cap on
|
||||
distinct page keys tracked per day, and writes through the same atomic
|
||||
writer as everything else — it previously used a plain `fs.writeFile`
|
||||
with an unbounded, request-supplied key space.
|
||||
- The rate limiter was a fixed window, not the sliding window it
|
||||
documented: a burst timed around the window boundary could clear close
|
||||
to double the configured limit. It is now a weighted two-bucket window,
|
||||
shared by every rate-limited route.
|
||||
- `install.json`, which holds the Immich API key and `authSecret`, was
|
||||
briefly world-readable between being written and a follow-up `chmod`.
|
||||
`atomicWrite` now accepts a file mode applied before the rename, so the
|
||||
file is never created with the wrong permissions.
|
||||
|
||||
- **Journal backup paths hardened against a CodeQL path-injection alert**
|
||||
([#586](https://github.com/ralksta/immich-folio/pull/586)). Not
|
||||
exploitable — the slug reaching `snapshotEntry` had already passed
|
||||
`isValidSlug` or the anchored backup-name pattern in every caller — but
|
||||
CodeQL could not follow that validation across modules. The backup path
|
||||
is now built through the same `containedPath()` guard every other path in
|
||||
the file already used, so a path that would escape `.backups/` throws
|
||||
instead of being trusted.
|
||||
|
||||
### Added
|
||||
|
||||
- **Diagnostics is a tab in the admin header, and its duplicate entry
|
||||
points are gone**
|
||||
([#621](https://github.com/ralksta/immich-folio/pull/621),
|
||||
[#656](https://github.com/ralksta/immich-folio/pull/656)). `/admin` and
|
||||
`/admin/pages` used to render the same page builder under two addresses;
|
||||
`/admin` now redirects to the one the Pages tab actually links to.
|
||||
Diagnostics, a real page since v0.15.0, was reachable only from the
|
||||
status dropdown and the Help page — it is now a tab of its own, and the
|
||||
now-redundant Backups/Diagnostics buttons in the status dropdown are
|
||||
gone (Backups keeps its header button).
|
||||
|
||||
- **Journal entries get previous/next navigation, subpages get a "next
|
||||
subpage" link, and both — plus `/about` — get a way back**
|
||||
([#658](https://github.com/ralksta/immich-folio/pull/658)). At the end of
|
||||
a journal entry, at the bottom of a subpage's album grid, and on the About
|
||||
page, there was previously no way onward or back except the browser's own
|
||||
button. Journal prev/next mirrors the existing album navigation: neighbours
|
||||
come from the same publication-order list the journal index renders, with
|
||||
no wrap-around, and a draft or a password-protected entry the visitor
|
||||
hasn't unlocked never appears here even by name. The "next subpage" link
|
||||
follows the same order the header navigation and homepage already use, so
|
||||
it never points somewhere hidden or disabled.
|
||||
|
||||
- **A proper mobile menu for the public header**
|
||||
([#659](https://github.com/ralksta/immich-folio/pull/659)). The header
|
||||
used to be one flat row that scrolled sideways on narrow viewports with
|
||||
the scrollbar hidden — nothing indicated that more sections existed
|
||||
off-screen, so the site appeared to have fewer than it does. Below a new
|
||||
640px breakpoint, a hamburger button opens the same links (never
|
||||
duplicated in the markup) as a full-width vertical panel; above it, the
|
||||
layout is pixel-identical to before.
|
||||
|
||||
- **`settings.yaml` is validated before it is written**
|
||||
([#623](https://github.com/ralksta/immich-folio/pull/623)).
|
||||
`PUT /api/admin/settings` used to cast the request body and write it
|
||||
straight through — the only admin save path with no shape check at all,
|
||||
and one that is read by every public page. A zod schema now checks
|
||||
structure (a scalar where a section belongs, a list where a record
|
||||
belongs), while value narrowing stays where the resolvers already do it,
|
||||
since only they also see hand-edited YAML.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Config values reach the page they render on, no longer silently
|
||||
discarded**
|
||||
([#645](https://github.com/ralksta/immich-folio/pull/645),
|
||||
[#618](https://github.com/ralksta/immich-folio/pull/618)). The admin
|
||||
panel rewrites `gallery.yaml`/`settings.yaml` wholesale on every save, so
|
||||
a field the panel doesn't know about isn't merely ignored — it's deleted.
|
||||
This hit an album's `location` precision and `download` opt-in (any
|
||||
unrelated save in the panel silently reset both to their defaults) and,
|
||||
separately, unclamped `grid.columns`/`theme.radius` values that could
|
||||
reach CSS as an invalid `repeat()` or a concatenated `8pxpx`.
|
||||
|
||||
- **The Immich client no longer serves a cached "not found" as real data
|
||||
during an outage**
|
||||
([#624](https://github.com/ralksta/immich-folio/pull/624)). Splitting
|
||||
transport and cache-policy concerns out of the 835-line client surfaced a
|
||||
real bug in the process: once a definitive-404 cache entry's TTL passed
|
||||
during an Immich outage, it came back as a truthy sentinel object with no
|
||||
fields, every `if (!asset)` guard passed on it, and the page answered 500
|
||||
for the length of the outage instead of falling back to the last known
|
||||
album. A stale "missing" now resolves to `null` — the answer every caller
|
||||
already handled — while every other stale-cache path still throws and
|
||||
lets the outage propagate correctly.
|
||||
|
||||
- **A never-checked expired admin session no longer fails silently**
|
||||
([#650](https://github.com/ralksta/immich-folio/pull/650)). Admin
|
||||
sessions last 24 hours, but nothing re-checked one after the initial page
|
||||
load — a tab left open overnight looked logged in while every save quietly
|
||||
answered 401. A 401 from any admin save or reload now drops back to the
|
||||
login screen with a notice instead of a confusing generic error or, for
|
||||
the reload button, no feedback at all.
|
||||
|
||||
- **Saving no longer overwrites a configuration that failed to load**
|
||||
([#619](https://github.com/ralksta/immich-folio/pull/619)). Each admin
|
||||
editor holds the whole file in memory and writes all of it back — so a
|
||||
load that quietly failed left an empty form indistinguishable from a
|
||||
freshly-configured site, and saving replaced the real file with that
|
||||
emptiness. A failed load now replaces the form with a notice and refuses
|
||||
to save, including against the keyboard shortcut, until a fresh load
|
||||
succeeds.
|
||||
|
||||
- **Removing an album, hero image or section entry now asks first**
|
||||
([#649](https://github.com/ralksta/immich-folio/pull/649)). Removing a
|
||||
subpage already asked for confirmation; removing an album — which also
|
||||
discards its grid overrides, cover asset and manual photo order — did
|
||||
not, with no undo besides reloading the page and losing every other
|
||||
unsaved edit.
|
||||
|
||||
- **The active nav link actually shows which page you're on**
|
||||
([#620](https://github.com/ralksta/immich-folio/pull/620)). The
|
||||
`.active` styling and its underline existed in the CSS since the header
|
||||
was written, but nothing ever applied the class, and `aria-current` was
|
||||
missing outright. Both the public header and the admin panel's own tabs
|
||||
now derive and announce the active section.
|
||||
|
||||
- **Pre-restore backups no longer crowd out ordinary ones**
|
||||
([#617](https://github.com/ralksta/immich-folio/pull/617)). Pre-restore
|
||||
snapshots were exempt from the ten-backup cap entirely, so after a dozen
|
||||
restores the backup manager showed almost nothing else. Each kind is now
|
||||
counted and capped separately, and pruning also runs on restore rather
|
||||
than waiting for the next save.
|
||||
|
||||
- **A failed backup now aborts the save instead of continuing without
|
||||
one**
|
||||
([#641](https://github.com/ralksta/immich-folio/pull/641)). Restoring a
|
||||
backup used a plain `fs.copyFile`, which truncates the live file before
|
||||
writing, and its own pre-restore safety copy swallowed every error as "the
|
||||
file probably doesn't exist yet." On a full content volume this could
|
||||
truncate `gallery.yaml` to zero bytes with no snapshot to recover from.
|
||||
Restores now write through the same atomic (temp-file-plus-rename)
|
||||
primitive as every other save, and only `ENOENT` is treated as "nothing to
|
||||
back up" — any other failure aborts before the live file is touched. The
|
||||
same swallow-everything shape existed in the ordinary save path for
|
||||
`gallery.yaml`, `settings.yaml`, journal entries and `about.md`, and got
|
||||
the same fix.
|
||||
|
||||
- **Editing a journal entry that came from the legacy `essays/` folder no
|
||||
longer resurrects its old version**
|
||||
([#639](https://github.com/ralksta/immich-folio/pull/639)). Saving always
|
||||
wrote to `content/journal/`, but a legacy entry kept its original copy in
|
||||
`content/essays/` too, and deleting only ever removed the new copy — so a
|
||||
"deleted" entry reappeared with its pre-edit text and frontmatter on the
|
||||
next request. Saving a legacy-origin entry now retires its old copy, and
|
||||
deletion checks both locations directly.
|
||||
|
||||
- **The journal serializer no longer mangles what it just parsed**
|
||||
([#640](https://github.com/ralksta/immich-folio/pull/640)). Re-saving an
|
||||
entry — including a bare frontmatter edit, which re-serializes the whole
|
||||
document — dropped every link in a paragraph, left literal HTML tags in
|
||||
quotes and photo captions, doubled HTML-entity-encoded apostrophes in
|
||||
photo-pair captions, and turned `my_photo_2024.jpg` into
|
||||
`my*photo*2024.jpg`. The serializer now walks the exact markup the
|
||||
renderer produces instead of stripping tags with a flat regex, and
|
||||
emphasis markers now require CommonMark's non-word boundary on both
|
||||
sides.
|
||||
|
||||
- **The setup wizard no longer overwrites an existing `gallery.yaml`**
|
||||
([#642](https://github.com/ralksta/immich-folio/pull/642)). The wizard
|
||||
already protected `settings.yaml` from being silently replaced, five
|
||||
lines above the identical unconditional overwrite of `gallery.yaml` — and
|
||||
unlike the admin panel's own writes, this path kept no backup. A site
|
||||
could reach the wizard again through a resolvable-but-wrong Immich URL or
|
||||
a corrupted `install.json`, at which point every subpage, password and
|
||||
per-album override was gone with nothing recoverable on disk. Both files
|
||||
now get the same "keep if it already exists" treatment; the wizard tells
|
||||
the operator when that happened.
|
||||
|
||||
- **`npm run doctor` no longer passes two broken deployments**
|
||||
([#643](https://github.com/ralksta/immich-folio/pull/643)). A
|
||||
`TRUSTED_PROXY_HOPS` value lower than the observed proxy chain (but above
|
||||
zero) fell through to the "matches the observed chain" success case
|
||||
instead of warning. Separately, the album-configuration check only ran
|
||||
when Immich's response was non-empty — so an API key pointed at the wrong
|
||||
Immich account, answering `200 []`, passed every check while every
|
||||
configured album page was actually empty.
|
||||
|
||||
- **Two subpages, or two albums on the same page, can no longer share a
|
||||
slug silently**
|
||||
([#644](https://github.com/ralksta/immich-folio/pull/644)). Subpage and
|
||||
album slugs are derived from their names with no uniqueness check, and
|
||||
`slugify` folds diacritics and punctuation readily enough that distinct
|
||||
names collide (`Portfolio 2024` and `Portfolio-2024` both become
|
||||
`portfolio-2024`). The second entry became unreachable, and its password
|
||||
and other settings silently resolved from the first. Colliding subpage
|
||||
names, and colliding album title overrides on the same page, are now
|
||||
rejected at save time with both names in the error; a collision that only
|
||||
becomes visible once Immich's own album names are known surfaces as a new
|
||||
diagnostics finding instead.
|
||||
|
||||
- **A trailing slash on the Immich URL no longer breaks every request
|
||||
after setup**
|
||||
([#646](https://github.com/ralksta/immich-folio/pull/646)). The install
|
||||
wizard's connection test normalised the URL before pinging it, but stored
|
||||
the raw input — so `https://immich.example.com/api/` passed the wizard
|
||||
and then sent every later request to a doubled `/api/api/` path. The
|
||||
stored value is now normalised the same way at both write and read time,
|
||||
so an `install.json` written before this fix heals without re-running the
|
||||
wizard.
|
||||
|
||||
- **Coordinates of exactly 0 no longer vanish from the map, and error
|
||||
responses no longer leak sockets**
|
||||
([#647](https://github.com/ralksta/immich-folio/pull/647)). The map's
|
||||
coordinate check used plain falsiness, so a photo on the equator or the
|
||||
prime meridian was treated as having no location at all. Separately,
|
||||
several Immich-facing error paths never read or cancelled the upstream
|
||||
response body before returning — under a sustained upstream fault this
|
||||
slowly exhausts the connection pool.
|
||||
|
||||
- **A shared photo link survives reordering or deleting other photos in
|
||||
the album**
|
||||
([#651](https://github.com/ralksta/immich-folio/pull/651)). The lightbox
|
||||
addressed a photo by its position in the album (`#photo-N`); reordering or
|
||||
deleting a photo silently repointed every previously shared link at a
|
||||
different image. Links now address the photo by its stable asset id via
|
||||
`?photo=<token>`, which also means a shared link's Open Graph preview can
|
||||
show the actual photo instead of a generic card. Links shared before this
|
||||
change keep working — the old `#photo-N` format is still read as a
|
||||
fallback.
|
||||
|
||||
### Internal
|
||||
|
||||
- **One `withAdmin()` wrapper instead of the same guard copied into 24
|
||||
route handlers**
|
||||
([#615](https://github.com/ralksta/immich-folio/pull/615)). The
|
||||
enabled-check-plus-auth-check pattern was hand-copied across 16 admin
|
||||
route files; a new route protected by nobody used to be a diff that
|
||||
looked completely ordinary. A structural test now rejects any admin
|
||||
handler exported outside the wrapper.
|
||||
- **One atomic-write primitive instead of six independently-drifted
|
||||
copies**
|
||||
([#616](https://github.com/ralksta/immich-folio/pull/616)). The
|
||||
temp-file-then-rename pattern existed six times across the admin, journal,
|
||||
install and favicon code, each with its own (and by now different) temp
|
||||
filename scheme.
|
||||
- **Component testing is set up**
|
||||
([#622](https://github.com/ralksta/immich-folio/pull/622)). `jsdom` and
|
||||
`@testing-library/react` land as dev dependencies, opt-in per file via a
|
||||
`// @vitest-environment jsdom` pragma; extracting logic out of a
|
||||
component is still preferred over rendering it wherever that's possible.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
**Bump `IMAGE_CACHE_VERSION` when you deploy this release**, the same
|
||||
variable v0.15.0 introduced. Image URLs are served `immutable` with a
|
||||
one-year cache; a browser (or an edge cache) that already has an `original`
|
||||
response cached under a still-valid image URL keeps serving that original
|
||||
after the upgrade unless the URL itself changes. Increment the value if it
|
||||
is already set; set it to anything if it is not.
|
||||
|
||||
No configuration schema changed beyond what `settings.yaml` validation
|
||||
already accepts; there is nothing else to migrate.
|
||||
|
||||
## [0.15.0] — 2026-09-19
|
||||
|
||||
### Security
|
||||
|
||||
@@ -55,6 +55,26 @@ npx vitest run # all tests must pass
|
||||
npm run build # build must succeed
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Tests live in `__tests__/` next to the code they cover, and run in Node by
|
||||
default — most of what is worth testing is plain logic.
|
||||
|
||||
A test that needs a DOM opts in per file, with this as its very first line:
|
||||
|
||||
```tsx
|
||||
// @vitest-environment jsdom
|
||||
```
|
||||
|
||||
Then render with `@testing-library/react` and call `cleanup` in `afterEach`.
|
||||
See `app/admin/__tests__/SaveBar.test.tsx` for a worked example.
|
||||
|
||||
**Prefer extracting the logic over rendering it.** A pure function moved into
|
||||
`lib/` — or into a sibling module, as `page-builder/albumEntries.ts` was — is
|
||||
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.
|
||||
|
||||
## Code style
|
||||
|
||||
- **TypeScript** — strict mode, no `any` without justification
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
* PhotoGrid — client component wrapping the masonry grid and lightbox.
|
||||
* Handles image click → lightbox open, keyboard nav, and EXIF fetching.
|
||||
*
|
||||
* Deep-link support: syncs lightbox index with the URL hash (#photo-N).
|
||||
* Sharing a link with a hash opens the lightbox to that photo directly.
|
||||
* Deep-link support: syncs the lightbox with `?photo=<assetId>`, a stable
|
||||
* per-photo id rather than a position, so reordering or deleting other
|
||||
* photos in the album does not repoint an already-shared link (#588). A
|
||||
* legacy `#photo-N` hash is still read on the initial load — those links
|
||||
* have been shared for months — but the grid never writes that form again.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -15,7 +18,7 @@ import { FadeIn } from '@/components/FadeIn';
|
||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
import { useDictionary } from '@/components/I18nProvider';
|
||||
import { parsePhotoHash, buildPhotoHash } from '@/lib/photoHash';
|
||||
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
|
||||
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
@@ -77,49 +80,73 @@ function PhotoGridInner({
|
||||
return assets;
|
||||
}, [assets, proofing]);
|
||||
|
||||
// ── Initial hash check (Client-only to avoid hydration mismatch) ──
|
||||
// ── Initial deep-link check (client-only to avoid hydration mismatch) ──
|
||||
// ?photo=<assetId> wins when present; #photo-N is read only as a fallback,
|
||||
// for a link shared before this format existed.
|
||||
useEffect(() => {
|
||||
const idx = parsePhotoHash(window.location.hash);
|
||||
if (idx !== null && idx < displayedAssets.length) {
|
||||
// state from URL hash on mount only
|
||||
const token = parsePhotoQuery(window.location.search);
|
||||
const byToken = token ? displayedAssets.findIndex((a) => a.id === token) : -1;
|
||||
if (byToken >= 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLightboxIndex(idx);
|
||||
setLightboxIndex(byToken);
|
||||
return;
|
||||
}
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
// ── Sync URL hash when lightbox state changes ─────────────────
|
||||
useEffect(() => {
|
||||
if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) {
|
||||
const hash = buildPhotoHash(lightboxIndex);
|
||||
if (window.location.hash !== hash) {
|
||||
window.history.replaceState(null, '', hash);
|
||||
}
|
||||
}
|
||||
}, [lightboxIndex, displayedAssets.length]);
|
||||
|
||||
// ── Listen for browser back/forward (hash change) ─────────────
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const idx = parsePhotoHash(window.location.hash);
|
||||
if (idx !== null && idx < displayedAssets.length) {
|
||||
setLightboxIndex(idx);
|
||||
} else {
|
||||
setLightboxIndex(null);
|
||||
}
|
||||
}, [displayedAssets]);
|
||||
|
||||
// ── Sync the URL's `photo` param when the lightbox state changes ──
|
||||
useEffect(() => {
|
||||
const assetId =
|
||||
lightboxIndex !== null && lightboxIndex < displayedAssets.length
|
||||
? displayedAssets[lightboxIndex].id
|
||||
: null;
|
||||
if (assetId === null) return;
|
||||
const query = buildPhotoQuery(window.location.search, assetId);
|
||||
// Building the full URL (not just the search string) drops any leftover
|
||||
// #photo-N hash from a legacy link this page was opened with.
|
||||
const url = window.location.pathname + query;
|
||||
if (window.location.pathname + window.location.search !== url || window.location.hash) {
|
||||
window.history.replaceState(null, '', url);
|
||||
}
|
||||
}, [lightboxIndex, displayedAssets]);
|
||||
|
||||
// ── Listen for browser back/forward ────────────────────────────
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const token = parsePhotoQuery(window.location.search);
|
||||
const byToken = token ? displayedAssets.findIndex((a) => a.id === token) : -1;
|
||||
setLightboxIndex(byToken >= 0 ? byToken : null);
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, [displayedAssets.length]);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [displayedAssets]);
|
||||
|
||||
const openLightbox = useCallback((index: number) => {
|
||||
const openLightbox = useCallback(
|
||||
(index: number) => {
|
||||
setLightboxIndex(index);
|
||||
window.history.pushState(null, '', buildPhotoHash(index));
|
||||
}, []);
|
||||
const asset = displayedAssets[index];
|
||||
if (asset) {
|
||||
window.history.pushState(
|
||||
null,
|
||||
'',
|
||||
window.location.pathname + buildPhotoQuery(window.location.search, asset.id),
|
||||
);
|
||||
}
|
||||
},
|
||||
[displayedAssets],
|
||||
);
|
||||
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxIndex(null);
|
||||
window.history.pushState(null, '', window.location.pathname + window.location.search);
|
||||
window.history.pushState(
|
||||
null,
|
||||
'',
|
||||
window.location.pathname + buildPhotoQuery(window.location.search, null),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Image from 'next/image';
|
||||
import { imageUrl } from '@/lib/urls';
|
||||
import { SubpageSectionConfig } from '@/lib/config';
|
||||
import { getServerDictionary } from '@/lib/i18n/server';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
|
||||
interface SubpageAlbum {
|
||||
id: string;
|
||||
@@ -30,6 +31,8 @@ interface SubpageGridViewProps {
|
||||
index?: number;
|
||||
/** `--subpage-columns` / `--subpage-gap` from the resolved grid config. */
|
||||
gridStyle?: React.CSSProperties;
|
||||
/** The subpage that follows this one in the nav/homepage order, if any (#591). */
|
||||
nextSubpage?: { slug: string; name: string };
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
@@ -120,6 +123,7 @@ export function SubpageGridView({
|
||||
sections,
|
||||
index,
|
||||
gridStyle,
|
||||
nextSubpage,
|
||||
}: SubpageGridViewProps) {
|
||||
// Build lookup maps once
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
@@ -131,9 +135,9 @@ export function SubpageGridView({
|
||||
|
||||
return (
|
||||
<div className="subpage-container">
|
||||
{(title || subtitle) && (
|
||||
<header className="subpage-header">
|
||||
<div className="subpage-header__main">
|
||||
<BackLink href="/" label={t.common.backToGallery} />
|
||||
{index !== undefined && (
|
||||
<p className="subpage-header__kicker" aria-hidden="true">
|
||||
{t.subpage.collectionKicker(pad2(index))}
|
||||
@@ -142,11 +146,12 @@ export function SubpageGridView({
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
{(title || subtitle) && (
|
||||
<p className="subpage-header__meta" aria-hidden="true">
|
||||
{t.common.albums(albums.length)} · {photoCount(totalPhotos)}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Typographic Table of Contents */}
|
||||
{hasSections && (
|
||||
@@ -194,6 +199,35 @@ export function SubpageGridView({
|
||||
gridStyle={gridStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Onward navigation to the next subpage in nav order (#591) — a subpage
|
||||
grid otherwise dead-ends into the browser's back button. */}
|
||||
{nextSubpage && (
|
||||
<nav className="album-nav" aria-label={t.subpage.nextSubpageAria(nextSubpage.name)}>
|
||||
<span aria-hidden="true" />
|
||||
<Link
|
||||
href={`/${nextSubpage.slug}`}
|
||||
className="album-nav__link album-nav__link--next"
|
||||
aria-label={t.subpage.nextSubpageAria(nextSubpage.name)}
|
||||
>
|
||||
<span className="album-nav__text">
|
||||
<span className="album-nav__kicker">{t.subpage.nextSubpage}</span>
|
||||
<span className="album-nav__name">{nextSubpage.name}</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
downloadUrl,
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
|
||||
import {
|
||||
buildCoverGridVars,
|
||||
getConfig,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
resolveProofing,
|
||||
type GridConfig,
|
||||
} from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import { isProtected, isAuthenticated, withoutLockedAlbums } from '@/lib/auth';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
|
||||
@@ -56,7 +56,21 @@ interface PathPageProps {
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
|
||||
/**
|
||||
* Whether `key` is password-protected and this request has not unlocked it.
|
||||
* generateMetadata runs unconditionally — unlike the page body, nothing
|
||||
* downstream of it stops a real title, photo count or cover image from
|
||||
* reaching an unauthenticated `<head>` unless this is checked first
|
||||
* (GHSA-fvgv-97g3-wjr7).
|
||||
*/
|
||||
async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
|
||||
if (!isProtected(key, type)) return false;
|
||||
const cookieStore = await cookies();
|
||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||
return !isAuthenticated(key, getCookie, type);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: PathPageProps): Promise<Metadata> {
|
||||
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
|
||||
// ("/家族相册") would never match a stored one. Decode once, here, and every
|
||||
// comparison downstream works on the same form (#522).
|
||||
@@ -64,58 +78,80 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
const path = rawPath?.map(normalizeSlug);
|
||||
if (!path || path.length === 0) return {};
|
||||
|
||||
// A shared photo link's whole point is that it reaches the server — unlike
|
||||
// the #photo-N hash it replaces, a `photo` query param is visible here, so
|
||||
// a link-preview bot can render the photo itself rather than the album's
|
||||
// generic card (#588).
|
||||
const sp = (await searchParams) || {};
|
||||
const photoToken = typeof sp.photo === 'string' ? sp.photo : undefined;
|
||||
const photoAssetId = photoToken ? decodeAssetId(photoToken) : null;
|
||||
let photoAsset: ImmichAsset | undefined;
|
||||
|
||||
const slug = path[0];
|
||||
let title = slug;
|
||||
let subtitle = '';
|
||||
let description: string | undefined = undefined;
|
||||
if (path.length === 1 && immich.isSubpageSlug(slug)) {
|
||||
const subpageLocked = await isLocked(slug, 'subpage');
|
||||
const result = await immich.getSubpageAlbums(slug);
|
||||
if (result) {
|
||||
if (result.subpage.subtitle) {
|
||||
if (!subpageLocked && result.subpage.subtitle) {
|
||||
description = result.subpage.subtitle;
|
||||
}
|
||||
if (result.albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
||||
if (album) {
|
||||
if (album && !subpageLocked && !(await isLocked(album.id, 'album'))) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
||||
}
|
||||
} else {
|
||||
} else if (!subpageLocked) {
|
||||
title = result.subpage.title || result.subpage.name;
|
||||
}
|
||||
}
|
||||
} else if (path.length === 2) {
|
||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||
if (album) {
|
||||
if (album && !(await isLocked(slug, 'subpage')) && !(await isLocked(album.id, 'album'))) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
||||
}
|
||||
} else {
|
||||
const album = await immich.getAlbumBySlug(slug);
|
||||
if (album) {
|
||||
if (album && !(await isLocked(album.id, 'album'))) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
||||
}
|
||||
}
|
||||
|
||||
const ogUrl = `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
|
||||
// The title stays the album's — it's still the context a reader wants —
|
||||
// but the description prefers the photo's own caption, and the image is
|
||||
// the photo itself rather than the generated text card.
|
||||
const ogDescription =
|
||||
photoAsset?.exifInfo?.description?.trim() ||
|
||||
description ||
|
||||
(subtitle ? `${title} — ${subtitle}` : undefined);
|
||||
const ogImage = photoAsset
|
||||
? imageUrl(photoAsset.id, 'preview')
|
||||
: `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
description: ogDescription,
|
||||
openGraph: {
|
||||
title,
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
images: [ogUrl],
|
||||
description: ogDescription,
|
||||
images: [ogImage],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
images: [ogUrl],
|
||||
description: ogDescription,
|
||||
images: [ogImage],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -229,7 +265,13 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
const { path: rawPath } = await params;
|
||||
const path = rawPath?.map(normalizeSlug);
|
||||
const sParams = (await searchParams) || {};
|
||||
const forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
|
||||
// ?fresh=1 / ?preview=true forces a cache-bypassing Immich refetch — cheap
|
||||
// to trigger, and every subpage request fans out into an album-list fetch
|
||||
// plus paged metadata search calls per album. Gated to admins so it cannot
|
||||
// be used to force-refresh (or, before the immich.ts fix alongside this,
|
||||
// to empty) the shared cache from an unauthenticated request.
|
||||
const forceFresh =
|
||||
(sParams.fresh === '1' || sParams.preview === 'true') && (await isAdminAuthenticated());
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
@@ -382,9 +424,13 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
? loadEssayFromFile(result.subpage.essayFile)
|
||||
: null;
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
// Fetch assets from the subpage's albums. An essay has no per-album
|
||||
// gate to pass through, so an album with its own password stays out
|
||||
// until it has been unlocked.
|
||||
const cookieStore = await cookies();
|
||||
const openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value);
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(
|
||||
@@ -400,7 +446,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
title: result.subpage.title || result.subpage.name,
|
||||
subtitle: result.subpage.subtitle,
|
||||
},
|
||||
blocks: albums.flatMap((a) => [
|
||||
blocks: openAlbums.flatMap((a) => [
|
||||
{ type: 'heading' as const, level: 2, text: a.albumName },
|
||||
...a.assets.map((asset) => ({
|
||||
type: 'photo' as const,
|
||||
@@ -508,6 +554,15 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
|
||||
const subpageIndex = enabledSubpages.findIndex((sp) => sp.slug === slug);
|
||||
|
||||
// The subpage that follows this one in the same list the nav/homepage
|
||||
// build from — hidden and disabled subpages are already excluded there,
|
||||
// so "next" never points somewhere the visitor could not otherwise reach
|
||||
// (#591).
|
||||
const navSubpages = await immich.getSubpages(forceFresh);
|
||||
const navIndex = navSubpages.findIndex((sp) => sp.slug === slug);
|
||||
const nextSubpage =
|
||||
navIndex >= 0 && navIndex < navSubpages.length - 1 ? navSubpages[navIndex + 1] : undefined;
|
||||
|
||||
return (
|
||||
<SubpageGridView
|
||||
slug={slug}
|
||||
@@ -518,6 +573,9 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
sections={result.subpage.sections}
|
||||
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
|
||||
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
||||
{...(nextSubpage
|
||||
? { nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.name } }
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { immich } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getServerDictionary } from '@/lib/i18n/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import './about.css';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -104,6 +105,7 @@ export default async function AboutPage() {
|
||||
|
||||
{/* ── Text ─────────────────────────────────────── */}
|
||||
<div className="about__text-col">
|
||||
<BackLink href="/" label={t.common.backToGallery} />
|
||||
<p className="about__kicker" aria-hidden="true">
|
||||
{t.about.kicker}
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import AdminLogin from './components/AdminLogin';
|
||||
import AdminDashboard from './components/AdminDashboard';
|
||||
import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry';
|
||||
import './admin.css';
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,13 @@ import './admin.css';
|
||||
export default function AdminShell({ children }: { children: ReactNode }) {
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
// Sessions last 24 hours (lib/admin/auth.ts), but nothing re-checked
|
||||
// authentication after the mount-time check below — leave a tab open
|
||||
// overnight and every admin request answered 401 while the UI still looked
|
||||
// signed in (#596). SESSION_EXPIRED_EVENT is raised by the fetch call sites
|
||||
// that noticed; this is what turns that into dropping back to the login
|
||||
// screen with an explanation instead of each of them failing quietly.
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAuth() {
|
||||
@@ -28,6 +36,15 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onSessionExpired() {
|
||||
setAuthenticated(false);
|
||||
setSessionExpired(true);
|
||||
}
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, onSessionExpired);
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, onSessionExpired);
|
||||
}, []);
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
@@ -51,7 +68,15 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return <AdminLogin onSuccess={() => setAuthenticated(true)} />;
|
||||
return (
|
||||
<AdminLogin
|
||||
notice={sessionExpired ? 'Your session expired. Sign in again.' : undefined}
|
||||
onSuccess={() => {
|
||||
setSessionExpired(false);
|
||||
setAuthenticated(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>;
|
||||
|
||||
71
app/admin/__tests__/AdminShell.test.tsx
Normal file
71
app/admin/__tests__/AdminShell.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
|
||||
import AdminShell from '../AdminShell';
|
||||
import { SESSION_EXPIRED_EVENT } from '../components/sessionExpiry';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/admin',
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/**
|
||||
* Sessions last 24 hours, but nothing re-checked authentication after the
|
||||
* mount-time check below — leave a tab open overnight and every admin
|
||||
* request answered 401 while the UI still looked signed in (#596).
|
||||
* SESSION_EXPIRED_EVENT, raised by any admin fetch call site that notices a
|
||||
* 401, is what this pins: it drops the whole panel back to the login screen
|
||||
* with an explanation, instead of each call site failing quietly.
|
||||
*/
|
||||
describe('AdminShell session expiry', () => {
|
||||
it('returns to the login screen with a notice when the session expires', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === '/api/admin/auth') {
|
||||
return new Response(JSON.stringify({ authenticated: true, enabled: true }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
// AdminDashboard's own status/doctor fetches — a failure there is
|
||||
// already handled as "unknown", not the thing under test here.
|
||||
return new Response(null, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminShell>
|
||||
<div>Panel content</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Panel content')).toBeTruthy());
|
||||
expect(screen.queryByLabelText('Password')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your session expired. Sign in again.')).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText('Panel content')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no notice on an ordinary first visit', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ authenticated: false, enabled: true }))),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminShell>
|
||||
<div>Panel content</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('Password')).toBeTruthy());
|
||||
expect(screen.queryByText('Your session expired. Sign in again.')).toBeNull();
|
||||
});
|
||||
});
|
||||
88
app/admin/__tests__/SaveBar.test.tsx
Normal file
88
app/admin/__tests__/SaveBar.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import SaveBar from '../components/SaveBar';
|
||||
|
||||
/**
|
||||
* The first component test in the project, and deliberately a small one: it is
|
||||
* here to show the shape (`// @vitest-environment jsdom` on the first line,
|
||||
* render, assert, cleanup) as much as to cover SaveBar.
|
||||
*
|
||||
* SaveBar earns it though. It decides whether an outcome reads as success or
|
||||
* failure by testing whether the message happens to start with "Error" — #600
|
||||
* replaces that with a typed state, and this pins the behaviour so the swap can
|
||||
* be verified rather than eyeballed.
|
||||
*/
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const props = {
|
||||
dirty: false,
|
||||
saving: false,
|
||||
saveMessage: '',
|
||||
onSave: () => {},
|
||||
label: 'Save',
|
||||
};
|
||||
|
||||
describe('SaveBar', () => {
|
||||
it('stays out of the way when there is nothing to act on', () => {
|
||||
const { container } = render(<SaveBar {...props} />);
|
||||
|
||||
// It is pinned over the page, so rendering it with nothing to say would
|
||||
// cover content for no reason.
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('announces unsaved changes', () => {
|
||||
render(<SaveBar {...props} dirty />);
|
||||
|
||||
expect(screen.getByText('Unsaved changes')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('only offers saving when there is something to save', () => {
|
||||
const { rerender } = render(<SaveBar {...props} saveMessage="Saved!" />);
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
|
||||
|
||||
rerender(<SaveBar {...props} dirty />);
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', false);
|
||||
});
|
||||
|
||||
it('blocks a second save while one is in flight', () => {
|
||||
render(<SaveBar {...props} dirty saving />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Saving...' });
|
||||
expect(button).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('calls onSave when clicked', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SaveBar {...props} dirty onSave={onSave} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('tells a failure from a success by the message prefix', () => {
|
||||
// Pinned as it is, not as it should be: the prefix check is the thing #600
|
||||
// replaces. A message that fails without starting with "Error" is styled as
|
||||
// a success today, which is precisely the argument for a typed state.
|
||||
const { rerender } = render(<SaveBar {...props} saveMessage="Error: Failed to save" />);
|
||||
expect(screen.getByText('Error: Failed to save').className).toContain('error');
|
||||
|
||||
rerender(<SaveBar {...props} saveMessage="Saved!" />);
|
||||
expect(screen.getByText('Saved!').className).toContain('success');
|
||||
|
||||
rerender(<SaveBar {...props} saveMessage="Could not reach the server" />);
|
||||
expect(screen.getByText('Could not reach the server').className).toContain('success');
|
||||
});
|
||||
|
||||
it('offers the preview link only where it belongs', () => {
|
||||
const { rerender } = render(<SaveBar {...props} dirty />);
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
|
||||
rerender(<SaveBar {...props} dirty showPreview />);
|
||||
// `?fresh=1` is what bypasses the cache, so the preview shows the save.
|
||||
expect(screen.getByRole('link')).toHaveProperty('href', expect.stringContaining('/?fresh=1'));
|
||||
});
|
||||
});
|
||||
80
app/admin/__tests__/album-entries-round-trip.test.ts
Normal file
80
app/admin/__tests__/album-entries-round-trip.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAlbumEntries,
|
||||
serializeAlbumEntries,
|
||||
hasAlbumOptions,
|
||||
} from '../components/page-builder/albumEntries';
|
||||
import type { AlbumEntryObject } from '@/lib/config/schema';
|
||||
|
||||
/**
|
||||
* The page builder rewrites gallery.yaml wholesale on every save, so a field it
|
||||
* does not know about is not merely ignored — it is deleted. `location` was
|
||||
* lost this way (#614): an album deliberately placed at city level, or hidden
|
||||
* from the map, silently went back to exact coordinates the next time anything
|
||||
* unrelated was saved.
|
||||
*
|
||||
* The round-trip test below is the part that matters. It is driven by a sample
|
||||
* entry carrying every key of AlbumEntryObject, so the next field added to the
|
||||
* schema and forgotten here fails this suite instead of quietly disappearing
|
||||
* from someone's configuration.
|
||||
*/
|
||||
|
||||
const uuid = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
/**
|
||||
* One value per key of AlbumEntryObject. The type annotation is the enforcement:
|
||||
* adding a key to the schema without adding it here is a type error, and the
|
||||
* round-trip test then proves the builder preserves it.
|
||||
*/
|
||||
const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObject, 'grid'> = {
|
||||
title: 'Kloster Chorin',
|
||||
description: 'Brick gothic in the Uckermark',
|
||||
password: 'hunter2',
|
||||
heroImage: '22222222-2222-2222-2222-222222222222',
|
||||
sort: 'oldest',
|
||||
assetOrder: ['33333333-3333-3333-3333-333333333333'],
|
||||
coverPosition: '50% 25%',
|
||||
download: true,
|
||||
location: 'hidden',
|
||||
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
|
||||
};
|
||||
|
||||
describe('album entry round trip', () => {
|
||||
it('preserves every option a gallery.yaml entry can carry', () => {
|
||||
const parsed = parseAlbumEntries([{ [uuid]: everyOption }]);
|
||||
const [serialized] = serializeAlbumEntries(parsed);
|
||||
|
||||
expect(serialized).toEqual({ [uuid]: everyOption });
|
||||
});
|
||||
|
||||
it('keeps map precision across a save', () => {
|
||||
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);
|
||||
|
||||
expect(parsed[0].location).toBe('city');
|
||||
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'city' } }]);
|
||||
});
|
||||
|
||||
it('does not collapse a location-only entry back to a bare id', () => {
|
||||
// This is the actual mechanism of the bug: an entry that looks "empty" is
|
||||
// written back as a plain UUID string, taking its options with it.
|
||||
const entry = { id: uuid, location: 'hidden' };
|
||||
|
||||
expect(hasAlbumOptions(entry)).toBe(true);
|
||||
expect(serializeAlbumEntries([entry])).toEqual([{ [uuid]: { location: 'hidden' } }]);
|
||||
});
|
||||
|
||||
it('carries a value this version does not recognise', () => {
|
||||
// Narrowing belongs to deriveGallery(), which rejects a typo loudly. The
|
||||
// builder's job is to hand back what it was given rather than drop it.
|
||||
const parsed = parseAlbumEntries([{ [uuid]: { location: 'district' } }]);
|
||||
|
||||
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { location: 'district' } }]);
|
||||
});
|
||||
|
||||
it('still collapses an entry that really is bare', () => {
|
||||
expect(serializeAlbumEntries([{ id: uuid }])).toEqual([uuid]);
|
||||
expect(serializeAlbumEntries([{ id: uuid, title: 'Just a title' }])).toEqual([
|
||||
{ [uuid]: 'Just a title' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
|
||||
import BackupManagerModal from './BackupManagerModal';
|
||||
import type { DoctorLevel } from '@/lib/admin/doctor';
|
||||
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface Props {
|
||||
@@ -15,10 +16,11 @@ interface Props {
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Pages', href: '/admin/pages', match: /^\/admin(\/pages)?$/ },
|
||||
{ label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/ },
|
||||
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
|
||||
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
|
||||
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
|
||||
{ label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/ },
|
||||
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
|
||||
];
|
||||
|
||||
@@ -47,7 +49,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
async function handleReload() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch('/api/admin/reload', { method: 'POST' });
|
||||
const res = await fetch('/api/admin/reload', { method: 'POST' });
|
||||
// A 401 or a 500 used to be indistinguishable from success here — the
|
||||
// result was never checked at all (#596).
|
||||
if (!res.ok) {
|
||||
if (!reportIfSessionExpired(res)) {
|
||||
alert(`Reload failed (HTTP ${res.status}).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Refresh status after reload
|
||||
await fetchStatus();
|
||||
} finally {
|
||||
@@ -141,15 +151,19 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
<div className="admin-header-left">
|
||||
<h1>Immich Folio</h1>
|
||||
<nav className="admin-tabs">
|
||||
{TABS.map((t) => (
|
||||
{TABS.map((t) => {
|
||||
const active = t.match.test(pathname);
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={`admin-tab ${t.match.test(pathname) ? 'active' : ''}`}
|
||||
className={`admin-tab ${active ? 'active' : ''}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="admin-header-right">
|
||||
@@ -247,24 +261,6 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-dropdown-footer">
|
||||
<Link
|
||||
href="/admin/diagnostics"
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => setShowStatus(false)}
|
||||
>
|
||||
<Icons.IconShieldCheck size={14} /> Diagnostics
|
||||
</Link>
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => {
|
||||
setShowStatus(false);
|
||||
setShowBackupModal(true);
|
||||
}}
|
||||
>
|
||||
<Icons.IconArchive size={14} /> Manage Backups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
/** Shown above the form, e.g. "Your session expired. Sign in again." (#596) */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export default function AdminLogin({ onSuccess }: Props) {
|
||||
export default function AdminLogin({ onSuccess, notice }: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -44,6 +46,8 @@ export default function AdminLogin({ onSuccess }: Props) {
|
||||
<p>Admin Panel</p>
|
||||
</div>
|
||||
|
||||
{notice && <div className="admin-notice">{notice}</div>}
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="admin-password">Password</label>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import * as Icons from './Icons';
|
||||
import { en } from '@/lib/i18n/locales/en';
|
||||
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
|
||||
@@ -70,17 +69,11 @@ export default function HelpView() {
|
||||
</span>
|
||||
<span className="settings-group-desc">
|
||||
Checks the things that fail silently — a wrong proxy hop count, a deleted album ID, a
|
||||
plaintext password, an album Immich does not consider shared. It also sits behind the
|
||||
status badge in the top bar, which is where it belongs when the question is whether
|
||||
anything needs attention right now.
|
||||
plaintext password, an album Immich does not consider shared. Open it from the{' '}
|
||||
<strong>Diagnostics</strong> tab in the top bar; the status badge next to it still gives
|
||||
a quick read on whether anything needs attention right now.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="help-action">
|
||||
<Link href="/admin/diagnostics" className="admin-btn">
|
||||
<Icons.IconShieldCheck size={14} /> Open diagnostics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import AssetPicker from './AssetPicker';
|
||||
import { BlockBadge } from './BlockBadge';
|
||||
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import { EssayView } from '@/app/[...path]/EssayView';
|
||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import './journal-studio.css';
|
||||
@@ -435,23 +436,33 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
/** Set when the entry could not be fetched; blocks saving over it. */
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Load entry
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`);
|
||||
if (res.ok) {
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
? 'Your session has expired. Sign in again to continue.'
|
||||
: `The server answered ${res.status}.`,
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
const md = data.entry.rawMarkdown;
|
||||
setRawMarkdown(md);
|
||||
setParsed(parseJournalMarkdown(md));
|
||||
setDirty(false);
|
||||
} else {
|
||||
alert('Failed to load journal entry');
|
||||
}
|
||||
} catch {
|
||||
alert('Error loading journal entry');
|
||||
} catch (err) {
|
||||
console.error('Failed to load journal entry:', err);
|
||||
// An empty editor saved over the entry replaces it with nothing, so
|
||||
// this blocks the save rather than only reporting it.
|
||||
setLoadError(err instanceof Error ? err.message : 'The entry could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -504,6 +515,9 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
|
||||
|
||||
// Save
|
||||
const handleSave = async () => {
|
||||
// The editor is not rendered in this state, but Cmd+S still reaches here.
|
||||
if (loadError) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`, {
|
||||
@@ -516,7 +530,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
|
||||
|
||||
if (res.ok) {
|
||||
setDirty(false);
|
||||
} else {
|
||||
} else if (!reportIfSessionExpired(res)) {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Failed to save');
|
||||
}
|
||||
@@ -636,6 +650,25 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the editor rather than sitting above it: an empty editor saved
|
||||
* over the entry would replace the text with nothing.
|
||||
*/
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="admin-error" role="alert">
|
||||
<strong>This entry could not be loaded.</strong> {loadError}
|
||||
<p>
|
||||
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
|
||||
overwrite the entry.
|
||||
</p>
|
||||
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
|
||||
Back to entries
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="journal-editor-container">
|
||||
{/* Top Bar */}
|
||||
|
||||
@@ -27,6 +27,7 @@ import AlbumDrawer from './page-builder/AlbumDrawer';
|
||||
import { SortableAlbumCard } from './page-builder/AlbumCard';
|
||||
import SubpageDrawer from './page-builder/SubpageDrawer';
|
||||
import { findAlbumAddress } from './page-builder/findAlbumAddress';
|
||||
import { parseAlbumEntries, serializeAlbumEntries } from './page-builder/albumEntries';
|
||||
import {
|
||||
seedCoverGrid,
|
||||
type ActiveEditAlbumAddress,
|
||||
@@ -40,8 +41,7 @@ import {
|
||||
} from './page-builder/types';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
|
||||
import { type AlbumEntryObject } from '@/lib/config/schema';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import {
|
||||
IconCamera,
|
||||
IconFolder,
|
||||
@@ -62,73 +62,6 @@ interface GalleryState {
|
||||
subpages: Subpage[];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
|
||||
|
||||
/**
|
||||
* Whether an entry carries anything beyond its ID.
|
||||
*
|
||||
* Both collapse rules below depend on this, and they are the reason every new
|
||||
* per-album option has to be listed here: an entry that looks "empty" is
|
||||
* serialized back to a bare UUID string, so a field missing from this check is
|
||||
* silently dropped on the next save.
|
||||
*/
|
||||
function hasAlbumOptions(entry: AlbumEntry): boolean {
|
||||
return Boolean(
|
||||
entry.description ||
|
||||
entry.password ||
|
||||
entry.heroImage ||
|
||||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
|
||||
entry.assetOrder?.length ||
|
||||
entry.grid ||
|
||||
entry.coverPosition,
|
||||
);
|
||||
}
|
||||
|
||||
function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
|
||||
if (!raw) return [];
|
||||
return raw.map((entry) => {
|
||||
if (typeof entry === 'string') return { id: entry };
|
||||
const [id, value] = Object.entries(entry)[0];
|
||||
if (typeof value === 'string') return { id, title: value };
|
||||
return {
|
||||
id,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
password: value.password,
|
||||
heroImage: value.heroImage,
|
||||
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
|
||||
assetOrder: value.assetOrder,
|
||||
grid: value.grid,
|
||||
coverPosition: value.coverPosition,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const extras = hasAlbumOptions(entry);
|
||||
if (!entry.title && !extras) return entry.id;
|
||||
if (entry.title && !extras) return { [entry.id]: entry.title };
|
||||
|
||||
const val: AlbumEntryObject = {};
|
||||
// Only when set: a sort-only entry would otherwise be written with an empty
|
||||
// title, which deriveGallery ignores but which still lands in the YAML.
|
||||
if (entry.title) val.title = entry.title;
|
||||
if (entry.description) val.description = entry.description;
|
||||
if (entry.password) val.password = entry.password;
|
||||
if (entry.heroImage) val.heroImage = entry.heroImage;
|
||||
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
|
||||
// Persisted regardless of the mode, so manual → newest → manual does not
|
||||
// throw away a hand-curated order.
|
||||
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
|
||||
if (entry.grid) val.grid = entry.grid;
|
||||
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
|
||||
return { [entry.id]: val };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sortable Hero Tile ─────────────────────────────────────────
|
||||
|
||||
function SortableHeroTile({
|
||||
@@ -269,6 +202,12 @@ export default function PageBuilder() {
|
||||
const [gallery, setGallery] = useState<GalleryState>({ hero: [], albums: [], subpages: [] });
|
||||
const [immichAlbums, setImmichAlbums] = useState<ImmichAlbumInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
/**
|
||||
* Set when gallery.yaml could not be fetched. It blocks saving: the builder
|
||||
* writes the whole file from its own state, so saving an empty builder
|
||||
* publishes an empty gallery.
|
||||
*/
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
@@ -356,25 +295,35 @@ export default function PageBuilder() {
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const [galleryRes, albumsRes] = await Promise.all([
|
||||
fetch('/api/admin/gallery'),
|
||||
fetch('/api/admin/albums'),
|
||||
]);
|
||||
|
||||
if (galleryRes.ok) {
|
||||
if (!galleryRes.ok) {
|
||||
throw new Error(
|
||||
galleryRes.status === 401
|
||||
? 'Your session has expired. Sign in again to continue.'
|
||||
: `The server answered ${galleryRes.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { gallery: raw } = await galleryRes.json();
|
||||
const parsed = parseGalleryYaml(raw);
|
||||
setGallery(parsed);
|
||||
openAlbumFromLink(parsed);
|
||||
}
|
||||
|
||||
// A failed album list is survivable — it only empties the picker, and
|
||||
// saving with it empty changes nothing in gallery.yaml.
|
||||
if (albumsRes.ok) {
|
||||
const { albums } = await albumsRes.json();
|
||||
setImmichAlbums(albums);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load admin data:', err);
|
||||
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -424,6 +373,7 @@ export default function PageBuilder() {
|
||||
password: sp.password as string | undefined,
|
||||
enabled: sp.enabled !== false,
|
||||
hidden: sp.hidden === true,
|
||||
location: sp.location as string | undefined,
|
||||
essayText: sp.essayText as string | undefined,
|
||||
essayFile: sp.essayFile as string | undefined,
|
||||
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
|
||||
@@ -451,6 +401,7 @@ export default function PageBuilder() {
|
||||
password: sp.password as string | undefined,
|
||||
enabled: sp.enabled !== false,
|
||||
hidden: sp.hidden === true,
|
||||
location: sp.location as string | undefined,
|
||||
essayText: sp.essayText as string | undefined,
|
||||
essayFile: sp.essayFile as string | undefined,
|
||||
albums: parseAlbumEntries(
|
||||
@@ -474,6 +425,9 @@ export default function PageBuilder() {
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
async function handleSave() {
|
||||
// Not reachable from the UI in this state, but Cmd+S still gets here.
|
||||
if (loadError) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveMessage('');
|
||||
|
||||
@@ -497,6 +451,7 @@ export default function PageBuilder() {
|
||||
if (sp.essayFile) entry.essayFile = sp.essayFile;
|
||||
if (sp.grid) entry.grid = sp.grid;
|
||||
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
|
||||
if (sp.location) entry.location = sp.location;
|
||||
|
||||
if (sp.sections && sp.sections.length > 0) {
|
||||
entry.sections = sp.sections.map((sec) => {
|
||||
@@ -530,7 +485,7 @@ export default function PageBuilder() {
|
||||
setDirty(false);
|
||||
setSaveMessage(data.message || 'Saved successfully!');
|
||||
setTimeout(() => setSaveMessage(''), 5000);
|
||||
} else {
|
||||
} else if (!reportIfSessionExpired(res)) {
|
||||
const err = await res.json();
|
||||
setSaveMessage(`Error: ${err.error}`);
|
||||
}
|
||||
@@ -729,7 +684,12 @@ export default function PageBuilder() {
|
||||
}
|
||||
|
||||
// ── Album Removal ────────────────────────────────────────────
|
||||
// Confirmed like removeSubpage: an album entry carries its grid overrides,
|
||||
// cover asset and manual assetOrder with it, there is no undo stack in the
|
||||
// builder, and the only escape from an accidental click used to be
|
||||
// reloading the page — which discards every other unsaved edit too (#597).
|
||||
function removeStandaloneAlbum(index: number) {
|
||||
if (!confirm('Remove this album from the gallery?')) return;
|
||||
setGallery((g) => ({
|
||||
...g,
|
||||
albums: g.albums.filter((_, i) => i !== index),
|
||||
@@ -738,6 +698,7 @@ export default function PageBuilder() {
|
||||
}
|
||||
|
||||
function removeSubpageAlbum(subpageIndex: number, albumIndex: number) {
|
||||
if (!confirm('Remove this album from the subpage?')) return;
|
||||
setGallery((g) => {
|
||||
const subpages = [...g.subpages];
|
||||
const sp = { ...subpages[subpageIndex] };
|
||||
@@ -749,6 +710,7 @@ export default function PageBuilder() {
|
||||
}
|
||||
|
||||
function removeSectionAlbum(subpageIndex: number, sectionIndex: number, albumIndex: number) {
|
||||
if (!confirm('Remove this album from the section?')) return;
|
||||
setGallery((g) => {
|
||||
const subpages = [...g.subpages];
|
||||
const sp = { ...subpages[subpageIndex] };
|
||||
@@ -765,6 +727,7 @@ export default function PageBuilder() {
|
||||
|
||||
// ── Hero Management ──────────────────────────────────────────
|
||||
function removeHero(index: number) {
|
||||
if (!confirm('Remove this photo from the home page hero?')) return;
|
||||
setGallery((g) => ({
|
||||
...g,
|
||||
hero: g.hero.filter((_, i) => i !== index),
|
||||
@@ -813,6 +776,26 @@ export default function PageBuilder() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the builder rather than sitting above it. An empty builder looks
|
||||
* exactly like a site with no pages yet, and saving it used to publish an
|
||||
* empty gallery.yaml over a working one.
|
||||
*/
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="admin-error" role="alert">
|
||||
<strong>The page structure could not be loaded.</strong> {loadError}
|
||||
<p>
|
||||
Nothing has been changed. Saving stays disabled until it loads, so an empty builder cannot
|
||||
overwrite your gallery.
|
||||
</p>
|
||||
<button className="admin-btn admin-btn-secondary" onClick={loadData}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter standalone albums
|
||||
const filteredAlbums = gallery.albums.filter((album) => {
|
||||
if (!searchQuery) return true;
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import * as Icons from './Icons';
|
||||
import SaveBar from './SaveBar';
|
||||
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import ToggleCard from './fields/ToggleCard';
|
||||
import OptionGrid, { toOptions } from './fields/OptionGrid';
|
||||
// Direct import from the theme module, not from '@/lib/config': the config
|
||||
@@ -587,6 +588,12 @@ export default function SettingsEditor() {
|
||||
source: 'env' | 'settings' | 'none';
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
/**
|
||||
* Set when the settings could not be fetched. It blocks saving, because an
|
||||
* empty form saved over a live settings.yaml replaces it with whatever one
|
||||
* field the user happened to touch.
|
||||
*/
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// Collapsed by default: the four metadata switches are a detail of one
|
||||
// decision, and showing them permanently is what made the section a wall (#510).
|
||||
const [metadataOpen, setMetadataOpen] = useState(false);
|
||||
@@ -671,15 +678,28 @@ export default function SettingsEditor() {
|
||||
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
if (res.ok) {
|
||||
if (!res.ok) {
|
||||
// 401 is the common one: the session lasts 24h and nothing re-checks
|
||||
// it, so a tab left open overnight lands here. reportIfSessionExpired
|
||||
// also drops the whole panel back to the login screen (#596); the
|
||||
// thrown message below still covers the brief moment before that
|
||||
// re-render happens, and any other non-ok status.
|
||||
reportIfSessionExpired(res);
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
? 'Your session has expired. Sign in again to continue.'
|
||||
: `The server answered ${res.status}.`,
|
||||
);
|
||||
}
|
||||
const { settings: data, siteUrl } = await res.json();
|
||||
setSettings(data || {});
|
||||
setSiteUrlInfo(siteUrl ?? null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -726,6 +746,9 @@ export default function SettingsEditor() {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// The form is not rendered in this state, but Cmd+S still reaches here.
|
||||
if (loadError) return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveMessage('');
|
||||
|
||||
@@ -750,9 +773,15 @@ export default function SettingsEditor() {
|
||||
setSaveMessage(data.message || 'Saved!');
|
||||
router.refresh();
|
||||
setTimeout(() => setSaveMessage(''), 5000);
|
||||
} else {
|
||||
} else if (!reportIfSessionExpired(res)) {
|
||||
const err = await res.json();
|
||||
setSaveMessage(`Error: ${err.error}`);
|
||||
// A rejected save names the fields that caused it. Listing them beats
|
||||
// "could not be saved" over a form with forty inputs; putting the
|
||||
// message next to each input is the job of #600.
|
||||
const fields: string[] = Array.isArray(err.fields)
|
||||
? err.fields.map((f: { field?: string }) => f.field || 'settings')
|
||||
: [];
|
||||
setSaveMessage(`Error: ${err.error}${fields.length ? ` — ${fields.join(', ')}` : ''}`);
|
||||
}
|
||||
} catch {
|
||||
setSaveMessage('Error: Failed to save');
|
||||
@@ -763,23 +792,36 @@ export default function SettingsEditor() {
|
||||
|
||||
async function loadAboutContent() {
|
||||
setAboutLoading(true);
|
||||
setAboutMessage('');
|
||||
try {
|
||||
const res = await fetch('/api/admin/about');
|
||||
if (res.ok) {
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
? 'Your session has expired. Sign in again to continue.'
|
||||
: `The server answered ${res.status}.`,
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
setAboutMeta(data.meta || {});
|
||||
setAboutBody(data.body || '');
|
||||
setAboutGearText(data.meta?.gear?.join('\n') || '');
|
||||
setAboutLoaded(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load about content:', err);
|
||||
// aboutLoaded stays false, which is what blocks the save below: an empty
|
||||
// editor written to about.md would replace the page with nothing.
|
||||
setAboutMessage(
|
||||
`Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`,
|
||||
);
|
||||
} finally {
|
||||
setAboutLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAboutContent() {
|
||||
if (!aboutLoaded) return;
|
||||
|
||||
setAboutSaving(true);
|
||||
setAboutMessage('');
|
||||
const cleanedMeta = { ...aboutMeta };
|
||||
@@ -842,6 +884,26 @@ export default function SettingsEditor() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately replaces the form rather than sitting above it. An empty form
|
||||
* is indistinguishable from a site with no settings yet, and editing one
|
||||
* field in it used to publish that field as the entire settings.yaml.
|
||||
*/
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="admin-error" role="alert">
|
||||
<strong>Settings could not be loaded.</strong> {loadError}
|
||||
<p>
|
||||
Nothing has been changed. Saving stays disabled until they load, so an empty form cannot
|
||||
overwrite your configuration.
|
||||
</p>
|
||||
<button className="admin-btn admin-btn-secondary" onClick={loadSettings}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved the same way the site resolves it, so the switches show what a
|
||||
// visitor actually sees — including a config that only ever set the older
|
||||
// `exifOnHover`.
|
||||
@@ -898,6 +960,7 @@ export default function SettingsEditor() {
|
||||
key={sec.id}
|
||||
href={`/admin/settings/${sec.id}`}
|
||||
className={`settings-nav-item ${activeSection === sec.id ? 'active' : ''}`}
|
||||
aria-current={activeSection === sec.id ? 'page' : undefined}
|
||||
>
|
||||
{sec.label}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import AdminDashboard from '../AdminDashboard';
|
||||
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/admin',
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/**
|
||||
* `handleReload` used to await POST /api/admin/reload without checking
|
||||
* `res.ok`, so a 401 or a 500 was indistinguishable from success (#596).
|
||||
*/
|
||||
describe('AdminDashboard reload failure reporting', () => {
|
||||
it('reports a non-ok, non-401 reload failure', async () => {
|
||||
const alertMock = vi.fn();
|
||||
vi.stubGlobal('alert', alertMock);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/reload' && init?.method === 'POST') {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
return new Response(null, { status: 500 }); // status/doctor probes
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminDashboard onLogout={() => {}}>
|
||||
<div>Panel</div>
|
||||
</AdminDashboard>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
||||
|
||||
await waitFor(() => expect(alertMock).toHaveBeenCalledWith('Reload failed (HTTP 500).'));
|
||||
});
|
||||
|
||||
it('raises the session-expired event instead of alerting on a 401', async () => {
|
||||
const alertMock = vi.fn();
|
||||
vi.stubGlobal('alert', alertMock);
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/reload' && init?.method === 'POST') {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
return new Response(null, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminDashboard onLogout={() => {}}>
|
||||
<div>Panel</div>
|
||||
</AdminDashboard>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
||||
|
||||
await waitFor(() => expect(listener).toHaveBeenCalledOnce());
|
||||
expect(alertMock).not.toHaveBeenCalled();
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||
});
|
||||
|
||||
it('does not alert when the reload succeeds', async () => {
|
||||
const alertMock = vi.fn();
|
||||
vi.stubGlobal('alert', alertMock);
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/admin/reload' && init?.method === 'POST') {
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
return new Response(null, { status: 500 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<AdminDashboard onLogout={() => {}}>
|
||||
<div>Panel</div>
|
||||
</AdminDashboard>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
([url, init]) => url === '/api/admin/reload' && init?.method === 'POST',
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
expect(alertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
38
app/admin/components/__tests__/sessionExpiry.test.ts
Normal file
38
app/admin/components/__tests__/sessionExpiry.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { SESSION_EXPIRED_EVENT, reportIfSessionExpired } from '../sessionExpiry';
|
||||
|
||||
/**
|
||||
* Sessions last 24 hours, but nothing re-checked authentication after the
|
||||
* mount-time check in AdminShell — every admin action after that just failed
|
||||
* quietly with the UI still looking signed in (#596). This is the one place
|
||||
* every admin fetch call site is meant to route a non-ok response through.
|
||||
*/
|
||||
describe('reportIfSessionExpired', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches the event and returns true for a 401', () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||
|
||||
const result = reportIfSessionExpired(new Response(null, { status: 401 }));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||
});
|
||||
|
||||
it('does nothing and returns false for other statuses', () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||
|
||||
for (const status of [200, 400, 403, 404, 500]) {
|
||||
expect(reportIfSessionExpired(new Response(null, { status }))).toBe(false);
|
||||
}
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||
});
|
||||
});
|
||||
87
app/admin/components/page-builder/albumEntries.ts
Normal file
87
app/admin/components/page-builder/albumEntries.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The gallery.yaml ↔ page-builder translation for album entries.
|
||||
*
|
||||
* Extracted from PageBuilder.tsx so it can be tested on its own: this is the
|
||||
* code that decides what survives a save, and a field it does not know about
|
||||
* is discarded silently rather than failing (#614).
|
||||
*/
|
||||
|
||||
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
|
||||
import { type AlbumEntryObject } from '@/lib/config/schema';
|
||||
import type { AlbumEntry } from './types';
|
||||
|
||||
export type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
|
||||
|
||||
/**
|
||||
* Whether an entry carries anything beyond its ID.
|
||||
*
|
||||
* Both collapse rules below depend on this, and they are the reason every new
|
||||
* per-album option has to be listed here: an entry that looks "empty" is
|
||||
* serialized back to a bare UUID string, so a field missing from this check is
|
||||
* silently dropped on the next save.
|
||||
*/
|
||||
export function hasAlbumOptions(entry: AlbumEntry): boolean {
|
||||
return Boolean(
|
||||
entry.description ||
|
||||
entry.password ||
|
||||
entry.heroImage ||
|
||||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
|
||||
entry.assetOrder?.length ||
|
||||
entry.grid ||
|
||||
entry.coverPosition ||
|
||||
entry.location ||
|
||||
entry.download,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
|
||||
if (!raw) return [];
|
||||
return raw.map((entry) => {
|
||||
if (typeof entry === 'string') return { id: entry };
|
||||
const [id, value] = Object.entries(entry)[0];
|
||||
if (typeof value === 'string') return { id, title: value };
|
||||
return {
|
||||
id,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
password: value.password,
|
||||
heroImage: value.heroImage,
|
||||
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
|
||||
assetOrder: value.assetOrder,
|
||||
grid: value.grid,
|
||||
coverPosition: value.coverPosition,
|
||||
location: value.location,
|
||||
download: value.download,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const extras = hasAlbumOptions(entry);
|
||||
if (!entry.title && !extras) return entry.id;
|
||||
if (entry.title && !extras) return { [entry.id]: entry.title };
|
||||
|
||||
const val: AlbumEntryObject = {};
|
||||
// Only when set: a sort-only entry would otherwise be written with an empty
|
||||
// title, which deriveGallery ignores but which still lands in the YAML.
|
||||
if (entry.title) val.title = entry.title;
|
||||
if (entry.description) val.description = entry.description;
|
||||
if (entry.password) val.password = entry.password;
|
||||
if (entry.heroImage) val.heroImage = entry.heroImage;
|
||||
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
|
||||
// Persisted regardless of the mode, so manual → newest → manual does not
|
||||
// throw away a hand-curated order.
|
||||
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
|
||||
if (entry.grid) val.grid = entry.grid;
|
||||
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
|
||||
// No UI sets this yet; it is preserved so that saving an unrelated change
|
||||
// cannot quietly republish exact coordinates for an album deliberately
|
||||
// placed at city level or hidden from the map (#469).
|
||||
if (entry.location) val.location = entry.location;
|
||||
// Same story as location: set by hand in YAML since #475, with no UI, so
|
||||
// the builder was turning original downloads back off on the next save.
|
||||
if (entry.download) val.download = entry.download;
|
||||
return { [entry.id]: val };
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,17 @@ export interface AlbumEntry {
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
|
||||
coverPosition?: string;
|
||||
/**
|
||||
* Map precision for this album: `exact`, `city`, `country` or `hidden` (#469).
|
||||
*
|
||||
* Carried verbatim rather than narrowed. The builder has no UI for it yet, so
|
||||
* its only job is to hand back exactly what it was given — including a value
|
||||
* this version does not recognise, which deriveGallery() is the right place
|
||||
* to reject.
|
||||
*/
|
||||
location?: string;
|
||||
/** Whether visitors may download the originals from this album (#475). */
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
@@ -37,6 +48,8 @@ export interface Subpage {
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
/** The album-cover tiles on this page only (#523). */
|
||||
coverGrid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
/** Map precision for every album here that does not set its own (#469). */
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
24
app/admin/components/sessionExpiry.ts
Normal file
24
app/admin/components/sessionExpiry.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Fired on `window` by an admin action whose request came back 401 — the
|
||||
* session cookie expired (sessions last 24 hours) or was cleared elsewhere.
|
||||
* AdminShell listens and drops back to the login screen with an explanation,
|
||||
* instead of each save/reload path failing quietly while the UI still looks
|
||||
* signed in (#596).
|
||||
*
|
||||
* Same pattern as DOCTOR_LEVEL_EVENT in systemHealth.ts: a window event
|
||||
* rather than a prop or context, because the components that need to raise
|
||||
* this (PageBuilder, SettingsEditor, JournalStudio, AdminDashboard) sit
|
||||
* several routes below AdminShell, which owns the auth gate.
|
||||
*/
|
||||
export const SESSION_EXPIRED_EVENT = 'folio:admin-session-expired';
|
||||
|
||||
/**
|
||||
* Call with the Response from an admin fetch. If it is a 401, dispatches the
|
||||
* event and returns true, so the caller can skip its own generic error
|
||||
* handling for that response and let AdminShell take over instead.
|
||||
*/
|
||||
export function reportIfSessionExpired(res: Response): boolean {
|
||||
if (res.status !== 401) return false;
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
|
||||
return true;
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import PageBuilder from './components/PageBuilder';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* `/admin` and `/admin/pages` both rendered the page builder, so the same
|
||||
* screen had two addresses: two routes to keep in step, two things to find
|
||||
* when something about the builder changes, and no way to tell which one a
|
||||
* bookmark or a bug report meant.
|
||||
*
|
||||
* `/admin/pages` wins because that is what the Pages tab links to. `/admin`
|
||||
* stays as the entry point people type and bookmark — and it is what the
|
||||
* public diagnostic banner links to — so it redirects rather than disappearing.
|
||||
*/
|
||||
export default function AdminHomePage() {
|
||||
return <PageBuilder />;
|
||||
redirect('/admin/pages');
|
||||
}
|
||||
|
||||
@@ -298,3 +298,15 @@
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Neutral, not an error: "your session expired" is expected behaviour after
|
||||
24 hours, not a mistake the operator made (#596). */
|
||||
.admin-notice {
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
color: var(--admin-text-secondary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -473,15 +473,6 @@ h3 .svg-icon {
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
/* ── Status Dropdown Footer ── */
|
||||
.status-dropdown-footer {
|
||||
padding: 0.5rem 0.85rem;
|
||||
background: var(--admin-bg-elevated);
|
||||
border-top: 1px solid var(--admin-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-btn-sm {
|
||||
padding: 0.35rem 0.65rem !important;
|
||||
font-size: 0.75rem !important;
|
||||
|
||||
@@ -60,10 +60,6 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.help-action {
|
||||
padding: 12px 4px 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.help-shortcuts-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
68
app/api/admin/__tests__/about-backup.test.ts
Normal file
68
app/api/admin/__tests__/about-backup.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/admin/auth', () => ({
|
||||
isAdminEnabled: vi.fn(() => true),
|
||||
isAdminAuthenticated: vi.fn(async () => true),
|
||||
COOKIE_NAME: 'folio_admin_session',
|
||||
}));
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
// The route imports from 'fs' directly; atomicWrite (used for the actual
|
||||
// write) imports from 'fs/promises'. Both need the same mock functions so a
|
||||
// test can see calls made through either.
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
access: vi.fn(async () => undefined),
|
||||
copyFile: vi.fn(async () => undefined),
|
||||
readdir: vi.fn(async () => []),
|
||||
unlink: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
rename: vi.fn(async () => undefined),
|
||||
readFile: vi.fn(async () => {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}),
|
||||
}));
|
||||
vi.mock('fs', () => ({ promises: fsMock }));
|
||||
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock }));
|
||||
|
||||
const fs = fsMock;
|
||||
import { PUT } from '../about/route';
|
||||
|
||||
const put = (body: unknown) =>
|
||||
PUT(
|
||||
new Request('http://localhost/api/admin/about', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* "No file yet" and "the backup could not be written" used to share one
|
||||
* catch, so a `.backups/` this save couldn't write to looked exactly like a
|
||||
* brand-new file and the save went ahead with no snapshot (#630).
|
||||
*/
|
||||
describe('PUT /api/admin/about backup handling', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('saves a brand-new file with no backup', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fs.copyFile).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('aborts the save when the file exists but the backup copy fails', async () => {
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(put({ meta: { name: 'Me' }, body: 'Hello' })).rejects.toThrow('EACCES');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -266,3 +266,62 @@ describe('admin route guards', () => {
|
||||
expect(uncovered, `Admin routes with no guard test: ${uncovered.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The table above proves each route refuses an unauthenticated caller. This
|
||||
* proves they all refuse it the *same way* — by going through withAdmin()
|
||||
* rather than by carrying their own copy of the check, which is how the two
|
||||
* refusals drifted apart in the first place.
|
||||
*/
|
||||
describe('admin routes are wrapped in withAdmin', () => {
|
||||
const read = async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const adminDir = path.join(process.cwd(), 'app/api/admin');
|
||||
|
||||
const files: { rel: string; src: string }[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '__tests__') continue;
|
||||
walk(full);
|
||||
} else if (entry.name === 'route.ts') {
|
||||
const rel = path.relative(adminDir, full).replace(/\\/g, '/');
|
||||
// The login endpoint must stay reachable without a session.
|
||||
if (rel.startsWith('auth/')) continue;
|
||||
files.push({ rel, src: fs.readFileSync(full, 'utf8') });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(adminDir);
|
||||
return files;
|
||||
};
|
||||
|
||||
it('exports no handler outside the wrapper', async () => {
|
||||
const offenders: string[] = [];
|
||||
for (const { rel, src } of await read()) {
|
||||
const bare = src.match(/export\s+(?:async\s+)?function\s+(GET|PUT|POST|DELETE|PATCH)\b/g);
|
||||
if (bare) offenders.push(`${rel}: ${bare.join(', ')}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`Admin route handlers not wrapped in withAdmin():\n${offenders.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('never re-implements the guard by hand', async () => {
|
||||
const offenders: string[] = [];
|
||||
for (const { rel, src } of await read()) {
|
||||
if (src.includes('isAdminEnabled') || src.includes('isAdminAuthenticated')) {
|
||||
offenders.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`These call the auth helpers directly instead of using withAdmin():\n${offenders.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,18 +14,28 @@ vi.mock('@/lib/admin/auth', () => ({
|
||||
COOKIE_NAME: 'folio_admin_session',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
const baseConfig = {
|
||||
needsCredentials: true,
|
||||
trustedProxyHops: 0,
|
||||
authSecret: 'x'.repeat(64),
|
||||
albums: [],
|
||||
subpages: [],
|
||||
albums: [] as string[],
|
||||
standaloneAlbums: [] as string[],
|
||||
albumOverrides: {} as Record<string, string>,
|
||||
subpages: [] as Array<{ name: string; albumIds: string[] }>,
|
||||
albumPasswords: {},
|
||||
sitePassword: '',
|
||||
immich: { apiUrl: '', apiKey: '' },
|
||||
immichTimeoutMs: 1000,
|
||||
}),
|
||||
};
|
||||
const getConfigMock = vi.fn(() => baseConfig);
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => getConfigMock(),
|
||||
slugify: (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
|
||||
@@ -89,3 +99,119 @@ describe('GET /api/admin/doctor', () => {
|
||||
expect(proxy.level).toBe('warn');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `if (albums.length)` used to skip the album check entirely when Immich
|
||||
* answered with an empty list — an API key regenerated under a different
|
||||
* account, say — so every connection check passed while every album page was
|
||||
* silently empty (#629).
|
||||
*/
|
||||
describe('GET /api/admin/doctor — album checks', () => {
|
||||
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
function withCredentialsAndAlbums(albums: string[]) {
|
||||
getConfigMock.mockReturnValueOnce({
|
||||
...baseConfig,
|
||||
needsCredentials: false,
|
||||
albums,
|
||||
immich: { apiUrl: 'https://immich.example', apiKey: 'key' },
|
||||
});
|
||||
}
|
||||
|
||||
it('reports every configured album as missing when Immich returns an empty list', async () => {
|
||||
withCredentialsAndAlbums([ALBUM_ID]);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.includes('/albums')) return { ok: true, json: async () => [] } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const albumFinding = body.findings.find((f: { id: string }) => f.id === 'album-ids');
|
||||
|
||||
expect(albumFinding?.level).toBe('error');
|
||||
expect(albumFinding?.title).toContain('1 of 1');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not reject a non-array album response outright', async () => {
|
||||
withCredentialsAndAlbums([ALBUM_ID]);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.includes('/albums')) return { ok: true, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const albumFinding = body.findings.find((f: { id: string }) => f.id === 'album-ids');
|
||||
|
||||
// A non-array body is treated the same as an empty list, not left uncast
|
||||
// and unchecked.
|
||||
expect(albumFinding?.level).toBe('error');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Two albums that slugify to the same URL leave the second unreachable and
|
||||
* its settings resolved from the first (#632).
|
||||
*/
|
||||
describe('GET /api/admin/doctor — album slug collisions', () => {
|
||||
const knownAlbums = [
|
||||
{ id: 'a', albumName: 'Japan Trip' },
|
||||
{ id: 'b', albumName: 'Japan-Trip' },
|
||||
];
|
||||
|
||||
function withCredentialsAndAlbums(config: Partial<typeof baseConfig>) {
|
||||
getConfigMock.mockReturnValueOnce({
|
||||
...baseConfig,
|
||||
needsCredentials: false,
|
||||
immich: { apiUrl: 'https://immich.example', apiKey: 'key' },
|
||||
...config,
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.includes('/albums')) return { ok: true, json: async () => knownAlbums } as Response;
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('flags two standalone albums whose Immich names slugify alike', async () => {
|
||||
withCredentialsAndAlbums({ albums: ['a', 'b'], standaloneAlbums: ['a', 'b'] });
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const finding = body.findings.find((f: { id: string }) => f.id === 'album-slugs');
|
||||
|
||||
expect(finding?.level).toBe('error');
|
||||
expect(finding?.detail).toContain('Japan Trip');
|
||||
expect(finding?.detail).toContain('Japan-Trip');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('is quiet when the same names live in different subpages', async () => {
|
||||
withCredentialsAndAlbums({
|
||||
albums: ['a', 'b'],
|
||||
standaloneAlbums: [],
|
||||
subpages: [
|
||||
{ name: 'Trips A', albumIds: ['a'] },
|
||||
{ name: 'Trips B', albumIds: ['b'] },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await (await GET(req())).json();
|
||||
const finding = body.findings.find((f: { id: string }) => f.id === 'album-slugs');
|
||||
|
||||
expect(finding?.level).toBe('ok');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
77
app/api/admin/__tests__/settings-write.test.ts
Normal file
77
app/api/admin/__tests__/settings-write.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/admin/auth', () => ({
|
||||
isAdminEnabled: vi.fn(() => true),
|
||||
isAdminAuthenticated: vi.fn(async () => true),
|
||||
COOKIE_NAME: 'folio_admin_session',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/yaml-service', () => ({
|
||||
readSettingsYaml: vi.fn(async () => ({})),
|
||||
writeSettingsYaml: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
invalidateConfigCache: vi.fn(),
|
||||
getConfigOrNull: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { invalidateAll: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { writeSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
import { PUT } from '../settings/route';
|
||||
|
||||
const put = (settings: unknown) =>
|
||||
PUT(
|
||||
new Request('http://localhost/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ settings }),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* The point of the check is not that a bad payload is reported — it is that
|
||||
* nothing is written. settings.yaml is read by every public page, so a
|
||||
* malformed save is discovered by visitors rather than by the person who made
|
||||
* it (#599).
|
||||
*/
|
||||
describe('PUT /api/admin/settings', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('writes a valid payload', async () => {
|
||||
const res = await put({ title: 'Folio', theme: 'minimal' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(writeSettingsYaml).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('leaves the file untouched when the payload is malformed', async () => {
|
||||
const res = await put({ title: { de: 'Folio' }, grid: 3 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(writeSettingsYaml).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers with the offending fields, so the panel can point at them', async () => {
|
||||
const res = await put({ title: 1, theme: { fonts: { heading: 42 } } });
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.fields.map((f: { field: string }) => f.field).sort()).toEqual([
|
||||
'theme.fonts.heading',
|
||||
'title',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still writes settings carrying keys this version does not know', async () => {
|
||||
// Older and newer configs are both in the wild, and there is no
|
||||
// schemaVersion to tell them apart.
|
||||
const res = await put({ title: 'Folio', fromALaterRelease: { nested: true } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(writeSettingsYaml).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { atomicWrite } from '@/lib/atomicWrite';
|
||||
|
||||
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
|
||||
const FILENAME = 'about.md';
|
||||
@@ -21,14 +22,7 @@ interface AboutBody {
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
const filePath = path.join(CONTENT_DIR, FILENAME);
|
||||
let meta: AboutMeta = {};
|
||||
let body = '';
|
||||
@@ -49,16 +43,9 @@ export async function GET() {
|
||||
}
|
||||
|
||||
return NextResponse.json({ meta, body });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
});
|
||||
|
||||
export const PUT = withAdmin(async (request: Request) => {
|
||||
const data = (await request.json().catch(() => null)) as AboutBody | null;
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
@@ -77,10 +64,22 @@ export async function PUT(request: Request) {
|
||||
const content = `---\n${frontmatter}\n---\n\n${data.body ?? ''}\n`;
|
||||
|
||||
const filePath = path.join(CONTENT_DIR, FILENAME);
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
|
||||
// ── Backup existing file ────────────────────────────────────
|
||||
// "No file yet" and "the backup could not be written" used to share one
|
||||
// catch, so a `.backups/` this save couldn't write to looked exactly like a
|
||||
// brand-new file: the save went ahead with no snapshot taken (#630). Only
|
||||
// ENOENT means there is nothing to back up; anything else aborts the save
|
||||
// before it overwrites the live file.
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException & { code?: string }).code !== 'ENOENT') throw err;
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
if (fileExists) {
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
@@ -92,22 +91,11 @@ export async function PUT(request: Request) {
|
||||
while (aboutBackups.length > MAX_BACKUPS) {
|
||||
await fs.unlink(path.join(backupDir, aboutBackups.shift()!));
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet — no backup needed, but make sure the dir exists
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// ── Atomic write (temp file + rename) ───────────────────────
|
||||
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content, 'utf-8');
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await atomicWrite(filePath, content);
|
||||
|
||||
revalidatePath('/about', 'layout');
|
||||
|
||||
return NextResponse.json({ success: true, message: 'About page saved.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,22 +4,13 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { isUuid } from '@/lib/uuid';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ albumId: string }> },
|
||||
) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ albumId: string }> }) => {
|
||||
const config = getConfig();
|
||||
// Credentials, not `needsSetup`: this route is how the operator picks the
|
||||
// albums that gallery.yaml is built from, so refusing to run until that file
|
||||
@@ -64,4 +55,5 @@ export async function GET(
|
||||
console.error('Failed to fetch album assets:', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch album assets' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
interface ImmichAlbumSummary {
|
||||
@@ -15,14 +15,7 @@ interface ImmichAlbumSummary {
|
||||
}
|
||||
|
||||
/** GET: List ALL shared Immich albums (not just allowlisted ones). */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
const config = getConfig();
|
||||
// Credentials, not `needsSetup`: this route is how the operator picks the
|
||||
// albums that gallery.yaml is built from, so refusing to run until that file
|
||||
@@ -75,4 +68,4 @@ export async function GET() {
|
||||
console.error('[Admin] Failed to fetch albums from Immich:', err);
|
||||
return NextResponse.json({ error: 'Failed to connect to Immich' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import {
|
||||
@@ -34,14 +34,7 @@ async function immichExternalDomain(apiUrl: string, apiKey: string, timeoutMs: n
|
||||
* public site: a check right after browsing the gallery costs no Immich calls.
|
||||
* An album that fails to load is counted, not fatal — the rest still report.
|
||||
*/
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
const config = getConfig();
|
||||
if (config.needsCredentials) {
|
||||
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
|
||||
@@ -80,4 +73,4 @@ export async function GET() {
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
|
||||
const ANALYTICS_FILE = path.join(process.cwd(), 'content', 'analytics.json');
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
try {
|
||||
const config = getConfigOrNull();
|
||||
let data;
|
||||
@@ -58,4 +51,4 @@ export async function GET() {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
interface ImmichSearchResult {
|
||||
@@ -22,14 +22,7 @@ interface ImmichSearchResult {
|
||||
}
|
||||
|
||||
/** GET: Search/browse assets from Immich for hero image selection. */
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async (request: NextRequest) => {
|
||||
const config = getConfig();
|
||||
// Credentials, not `needsSetup`: this route is how the operator picks the
|
||||
// albums that gallery.yaml is built from, so refusing to run until that file
|
||||
@@ -87,4 +80,4 @@ export async function GET(request: NextRequest) {
|
||||
console.error('[Admin] Failed to fetch assets from Immich:', err);
|
||||
return NextResponse.json({ error: 'Failed to connect to Immich' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { listBackups, restoreBackup } from '@/lib/admin/yaml-service';
|
||||
import { listJournalBackups, restoreJournalBackup } from '@/lib/admin/journal-service';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
@@ -44,14 +44,7 @@ function parseBackupInfo(filename: string, target: BackupTarget): BackupItem {
|
||||
}
|
||||
|
||||
/** GET: List all available backups, grouped by the file they restore. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
try {
|
||||
const gallery = (await listBackups('gallery.yaml')).map((f) => parseBackupInfo(f, 'gallery'));
|
||||
const settings = (await listBackups('settings.yaml')).map((f) =>
|
||||
@@ -79,17 +72,10 @@ export async function GET() {
|
||||
console.error('[Admin API] Error listing backups:', err);
|
||||
return NextResponse.json({ error: 'Failed to list backups' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** POST: Restore a specific backup file. */
|
||||
export async function POST(req: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const POST = withAdmin(async (req: Request) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { backupFilename, target } = body || {};
|
||||
@@ -132,4 +118,4 @@ export async function POST(req: Request) {
|
||||
console.error('[Admin API] Error restoring backup:', err);
|
||||
return NextResponse.json({ error: 'Failed to restore backup' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getConfig, slugify } from '@/lib/config';
|
||||
import { env } from '@/lib/env';
|
||||
import { listJournalEntries } from '@/lib/admin/journal-service';
|
||||
import {
|
||||
checkAlbumIds,
|
||||
checkAlbumSlugCollisions,
|
||||
checkAlbumsShared,
|
||||
checkAuthSecret,
|
||||
checkImmichCalls,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
PROXY_MARKER_HEADERS,
|
||||
worstLevel,
|
||||
type AlbumRef,
|
||||
type AlbumSlugGroup,
|
||||
type DoctorFinding,
|
||||
type PasswordRef,
|
||||
} from '@/lib/admin/doctor';
|
||||
@@ -28,14 +30,7 @@ import {
|
||||
* rather than failing the whole report, because a broken install is exactly
|
||||
* when this route needs to answer.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async (request: NextRequest) => {
|
||||
const config = getConfig();
|
||||
const findings: DoctorFinding[] = [];
|
||||
|
||||
@@ -87,18 +82,35 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (albumRes) {
|
||||
try {
|
||||
albums = (await albumRes.json()) as AlbumRef[];
|
||||
const parsed: unknown = await albumRes.json();
|
||||
if (Array.isArray(parsed)) albums = parsed as AlbumRef[];
|
||||
} catch {
|
||||
// A malformed body is already reflected by the call above.
|
||||
}
|
||||
}
|
||||
|
||||
findings.push(checkImmichCalls(calls));
|
||||
}
|
||||
|
||||
if (albums.length) {
|
||||
// Whenever albums are configured, run the check even if Immich answered
|
||||
// with an empty list — an API key regenerated under a different account,
|
||||
// say. `if (albums.length)` used to skip this entirely, so a `200 []`
|
||||
// response passed every connection check and reported nothing about
|
||||
// albums at all, while every album page was silently empty (#629).
|
||||
// checkAlbumIds treats every configured ID as missing when none resolve.
|
||||
if (config.albums.length) {
|
||||
findings.push(checkAlbumIds(config.albums, albums));
|
||||
findings.push(checkAlbumsShared(config.albums, albums));
|
||||
|
||||
// deriveGallery already rejects a slug collision between two albums
|
||||
// that both have a title override; this is the other half, using the
|
||||
// album's resolved name (override, or otherwise whatever Immich calls
|
||||
// it), which is only known once Immich has answered (#632).
|
||||
const slugGroups: AlbumSlugGroup[] = [
|
||||
{ context: 'gallery.yaml albums', albumIds: config.standaloneAlbums },
|
||||
...config.subpages.map((sp) => ({ context: `subpage "${sp.name}"`, albumIds: sp.albumIds })),
|
||||
];
|
||||
findings.push(checkAlbumSlugCollisions(slugGroups, config.albumOverrides, albums, slugify));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Passwords: every place one can be configured ─────────────────────
|
||||
@@ -144,4 +156,4 @@ export async function GET(request: NextRequest) {
|
||||
{ level: worstLevel(findings), findings },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { atomicWrite } from '@/lib/atomicWrite';
|
||||
|
||||
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
|
||||
const DEST = 'favicon.svg';
|
||||
@@ -16,24 +17,10 @@ const MAX_SIZE = 512 * 1024; // 512 kB
|
||||
async function writeFavicon(content: Buffer | string): Promise<void> {
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
const dest = path.join(CONTENT_DIR, DEST);
|
||||
const tmpPath = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content);
|
||||
await fs.rename(tmpPath, dest);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await atomicWrite(dest, content);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const PUT = withAdmin(async (request: Request) => {
|
||||
let body: FormData;
|
||||
try {
|
||||
body = await request.formData();
|
||||
@@ -88,17 +75,10 @@ export async function PUT(request: Request) {
|
||||
|
||||
await writeFavicon(svg);
|
||||
return NextResponse.json({ success: true, message: 'Favicon updated.' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Clean up stale favicon files left by a previous upload of a different format. */
|
||||
export async function DELETE() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const DELETE = withAdmin(async () => {
|
||||
try {
|
||||
await fs.unlink(path.join(CONTENT_DIR, DEST));
|
||||
return NextResponse.json({
|
||||
@@ -108,4 +88,4 @@ export async function DELETE() {
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'No custom favicon to remove' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { readGalleryYaml, writeGalleryYaml } from '@/lib/admin/yaml-service';
|
||||
import { invalidateConfigCache, deriveGallery } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import type { GalleryYaml } from '@/lib/config/schema';
|
||||
|
||||
/** GET: Read current gallery.yaml config. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
const gallery = await readGalleryYaml();
|
||||
return NextResponse.json({ gallery: gallery || { hero: [], albums: [], subpages: [] } });
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT: Write gallery.yaml config. */
|
||||
export async function PUT(request: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const PUT = withAdmin(async (request: Request) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.gallery) {
|
||||
return NextResponse.json({ error: 'Missing gallery data' }, { status: 400 });
|
||||
@@ -72,4 +58,4 @@ export async function PUT(request: Request) {
|
||||
console.error('[Admin] Failed to write gallery.yaml:', err);
|
||||
return NextResponse.json({ error: 'Failed to save gallery config' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal';
|
||||
import {
|
||||
readJournalEntry,
|
||||
@@ -11,14 +11,7 @@ interface RouteContext {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async (request: Request, context: RouteContext) => {
|
||||
const { slug } = await context.params;
|
||||
if (!isValidSlug(slug)) {
|
||||
return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
|
||||
@@ -34,16 +27,9 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
console.error(`[Admin API] Failed to get journal entry "${slug}":`, err);
|
||||
return NextResponse.json({ error: 'Failed to read journal entry' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
});
|
||||
|
||||
export const PUT = withAdmin(async (request: Request, context: RouteContext) => {
|
||||
const { slug } = await context.params;
|
||||
if (!isValidSlug(slug)) {
|
||||
return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
|
||||
@@ -85,16 +71,9 @@ export async function PUT(request: Request, context: RouteContext) {
|
||||
console.error(`[Admin API] Failed to update journal entry "${slug}":`, err);
|
||||
return NextResponse.json({ error: 'Failed to update journal entry' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
});
|
||||
|
||||
export const DELETE = withAdmin(async (request: Request, context: RouteContext) => {
|
||||
const { slug } = await context.params;
|
||||
if (!isValidSlug(slug)) {
|
||||
return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
|
||||
@@ -110,4 +89,4 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
console.error(`[Admin API] Failed to delete journal entry "${slug}":`, err);
|
||||
return NextResponse.json({ error: 'Failed to delete journal entry' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { sanitizeSlug, isValidSlug, type JournalFrontmatter } from '@/lib/journal';
|
||||
import {
|
||||
listJournalEntries,
|
||||
@@ -7,14 +7,7 @@ import {
|
||||
readJournalEntry,
|
||||
} from '@/lib/admin/journal-service';
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
try {
|
||||
const entries = await listJournalEntries();
|
||||
return NextResponse.json({ entries });
|
||||
@@ -22,16 +15,9 @@ export async function GET() {
|
||||
console.error('[Admin API] Failed to list journal entries:', err);
|
||||
return NextResponse.json({ error: 'Failed to list journal entries' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
});
|
||||
|
||||
export const POST = withAdmin(async (request: Request) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const rawTitle = typeof body.title === 'string' ? body.title.trim() : 'Untitled';
|
||||
@@ -73,4 +59,4 @@ export async function POST(request: Request) {
|
||||
console.error('[Admin API] Failed to create journal entry:', err);
|
||||
return NextResponse.json({ error: 'Failed to create journal entry' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
/** POST: Force reload config and clear caches. */
|
||||
export async function POST() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const POST = withAdmin(async () => {
|
||||
invalidateConfigCache();
|
||||
immich.invalidateAll();
|
||||
revalidatePath('/', 'layout');
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Config and cache reloaded' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
import { invalidateConfigCache, getConfigOrNull } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { validateSettings } from '@/lib/config/settingsSchema';
|
||||
import type { SettingsYaml } from '@/lib/config/schema';
|
||||
|
||||
/** GET: Read current settings.yaml config. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
const settings = await readSettingsYaml();
|
||||
const config = getConfigOrNull();
|
||||
return NextResponse.json({
|
||||
@@ -26,22 +20,28 @@ export async function GET() {
|
||||
source: config?.siteUrlSource ?? 'none',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT: Write settings.yaml config. */
|
||||
export async function PUT(request: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const PUT = withAdmin(async (request: Request) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.settings) {
|
||||
return NextResponse.json({ error: 'Missing settings data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Checked before the write, not after: settings.yaml is read by every public
|
||||
// page, so a malformed save is discovered by visitors rather than here.
|
||||
const validation = validateSettings(body.settings);
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'These settings could not be saved.',
|
||||
fields: validation.errors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const settings = body.settings as SettingsYaml;
|
||||
|
||||
try {
|
||||
@@ -57,4 +57,4 @@ export async function PUT(request: Request) {
|
||||
console.error('[Admin] Failed to write settings.yaml:', err);
|
||||
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { cache } from '@/lib/cache';
|
||||
import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
import { getUpdateStatus } from '@/lib/updateCheck';
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(async () => {
|
||||
// 1. Immich Connection Status
|
||||
let immichOk = false;
|
||||
try {
|
||||
@@ -96,4 +89,4 @@ export async function GET() {
|
||||
},
|
||||
update,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,18 +4,12 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { isUuid } from '@/lib/uuid';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
export const GET = withAdmin(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const config = getConfig();
|
||||
// Credentials, not `needsSetup`: this route is how the operator picks the
|
||||
// albums that gallery.yaml is built from, so refusing to run until that file
|
||||
@@ -31,12 +25,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail`, {
|
||||
const res = await fetch(
|
||||
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail`,
|
||||
{
|
||||
headers: {
|
||||
'x-api-key': config.immich.apiKey,
|
||||
Accept: 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
||||
@@ -55,4 +52,5 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
console.error('[Admin Thumbnail] Failed to fetch:', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch thumbnail' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
155
app/api/analytics/__tests__/track-route.test.ts
Normal file
155
app/api/analytics/__tests__/track-route.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* POST /api/analytics/track had none of the safeguards every comparable route
|
||||
* has: no rate limit, an unbounded key space (`pagePath` straight from the
|
||||
* request body becomes a permanent object key), and a plain `fs.writeFile`
|
||||
* that a process killed mid-write truncates — which the next read then
|
||||
* silently resets to zero (GHSA-w293-x8pc-j4cv).
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfigOrNull: vi.fn(() => ({ analytics: true })),
|
||||
}));
|
||||
|
||||
const checkRateLimit = vi.fn(
|
||||
(_ip: string, _maxRpm: number): { success: boolean; remaining: number; resetAt: number } => ({
|
||||
success: true,
|
||||
remaining: 59,
|
||||
resetAt: 0,
|
||||
}),
|
||||
);
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
checkRateLimit: (ip: string, maxRpm: number) => checkRateLimit(ip, maxRpm),
|
||||
getClientIp: vi.fn(() => '127.0.0.1'),
|
||||
retryAfterSeconds: vi.fn(() => 60),
|
||||
}));
|
||||
|
||||
const atomicWrite = vi.fn(async (_filePath: string, _content: string): Promise<void> => undefined);
|
||||
vi.mock('@/lib/atomicWrite', () => ({
|
||||
atomicWrite: (filePath: string, content: string) => atomicWrite(filePath, content),
|
||||
}));
|
||||
|
||||
let fixtureAnalytics: unknown = null;
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => {
|
||||
if (fixtureAnalytics === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
return JSON.stringify(fixtureAnalytics);
|
||||
}),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from '../track/route';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
|
||||
const mockConfig = getConfigOrNull as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const post = (body: unknown) =>
|
||||
POST(
|
||||
new Request('http://localhost/api/analytics/track', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64)' },
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const savedData = () => {
|
||||
const call = atomicWrite.mock.calls.at(-1);
|
||||
return JSON.parse(String(call?.[1]));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
checkRateLimit.mockReturnValue({ success: true, remaining: 59, resetAt: 0 });
|
||||
mockConfig.mockReturnValue({ analytics: true });
|
||||
fixtureAnalytics = null;
|
||||
});
|
||||
|
||||
describe('POST /api/analytics/track', () => {
|
||||
it('records a pageview and writes through atomicWrite, not a raw write', async () => {
|
||||
const res = await post({ path: '/deutschland/kloster-chorin' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(atomicWrite).toHaveBeenCalledOnce();
|
||||
const data = savedData();
|
||||
expect(data.summary.totalViews).toBe(1);
|
||||
const [dateKey] = Object.keys(data.days);
|
||||
expect(data.days[dateKey].pages['/deutschland/kloster-chorin']).toBe(1);
|
||||
});
|
||||
|
||||
it('is rate-limited per IP', async () => {
|
||||
checkRateLimit.mockReturnValue({ success: false, remaining: 0, resetAt: Date.now() + 60_000 });
|
||||
|
||||
const res = await post({ path: '/' });
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get('Retry-After')).toBeTruthy();
|
||||
expect(atomicWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('truncates a path far longer than any real route', async () => {
|
||||
const huge = '/' + 'a'.repeat(1000);
|
||||
await post({ path: huge });
|
||||
|
||||
const data = savedData();
|
||||
const [dateKey] = Object.keys(data.days);
|
||||
const keys = Object.keys(data.days[dateKey].pages);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0].length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('drops the query string from the path', async () => {
|
||||
await post({ path: '/journal?utm_source=newsletter' });
|
||||
|
||||
const data = savedData();
|
||||
const [dateKey] = Object.keys(data.days);
|
||||
expect(Object.keys(data.days[dateKey].pages)).toEqual(['/journal']);
|
||||
});
|
||||
|
||||
it('caps distinct page keys per day without dropping the pageview itself', async () => {
|
||||
const dateKey = new Date().toISOString().split('T')[0];
|
||||
const pages: Record<string, number> = {};
|
||||
for (let i = 0; i < 500; i++) pages[`/existing-${i}`] = 1;
|
||||
fixtureAnalytics = {
|
||||
summary: { totalViews: 500, lastUpdated: '' },
|
||||
days: { [dateKey]: { pageviews: 500, pages, devices: {} } },
|
||||
};
|
||||
|
||||
const res = await post({ path: '/one-path-too-many' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = savedData();
|
||||
// The day's total still counts this visit...
|
||||
expect(data.days[dateKey].pageviews).toBe(501);
|
||||
// ...but the 501st distinct path did not get its own key.
|
||||
expect(Object.keys(data.days[dateKey].pages)).toHaveLength(500);
|
||||
expect(data.days[dateKey].pages['/one-path-too-many']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still increments an already-tracked path past the cap', async () => {
|
||||
const dateKey = new Date().toISOString().split('T')[0];
|
||||
const pages: Record<string, number> = { '/journal': 10 };
|
||||
for (let i = 0; i < 500; i++) pages[`/existing-${i}`] = 1;
|
||||
fixtureAnalytics = {
|
||||
summary: { totalViews: 510, lastUpdated: '' },
|
||||
days: { [dateKey]: { pageviews: 510, pages, devices: {} } },
|
||||
};
|
||||
|
||||
await post({ path: '/journal' });
|
||||
|
||||
const data = savedData();
|
||||
expect(data.days[dateKey].pages['/journal']).toBe(11);
|
||||
});
|
||||
|
||||
it('does nothing when analytics are disabled', async () => {
|
||||
mockConfig.mockReturnValue({ analytics: false });
|
||||
|
||||
const res = await post({ path: '/' });
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.trackingDisabled).toBe(true);
|
||||
expect(atomicWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
import { atomicWrite } from '@/lib/atomicWrite';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const ANALYTICS_FILE = path.join(CONTENT_DIR, 'analytics.json');
|
||||
|
||||
/** Requests per minute per IP. One real pageview fires this once. */
|
||||
const ANALYTICS_RPM = 60;
|
||||
|
||||
/**
|
||||
* Distinct page paths tracked per day. Every route this comparably-sized site
|
||||
* has, many times over — high enough that no real page is ever dropped, low
|
||||
* enough that a flood of made-up paths cannot grow the file without bound.
|
||||
* A path over the cap still counts toward the day's total pageviews; it just
|
||||
* does not get its own key.
|
||||
*/
|
||||
const MAX_PAGES_PER_DAY = 500;
|
||||
|
||||
/** Well past any real route on this app; anything longer is not a path someone navigated to. */
|
||||
const MAX_PATH_LENGTH = 200;
|
||||
|
||||
interface AnalyticsData {
|
||||
summary: {
|
||||
totalViews: number;
|
||||
@@ -33,26 +50,52 @@ async function getAnalytics(): Promise<AnalyticsData> {
|
||||
async function saveAnalytics(data: AnalyticsData) {
|
||||
try {
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
await fs.writeFile(ANALYTICS_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||
// Temp-file + rename, not a plain write: a process killed mid-write used
|
||||
// to truncate the file, and a truncated (unparseable) file made the next
|
||||
// getAnalytics() silently reset every count to zero.
|
||||
await atomicWrite(ANALYTICS_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to save analytics data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
/**
|
||||
* Serializes the read-modify-write below within this process. Two concurrent
|
||||
* requests each reading the file, incrementing their own in-memory copy and
|
||||
* writing it back would silently drop whichever one wrote last — the file's
|
||||
* own atomicity (saveAnalytics above) does not cover that race, only the
|
||||
* write itself.
|
||||
*/
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const config = getConfigOrNull();
|
||||
if (config?.analytics === false) {
|
||||
return NextResponse.json({ ok: true, trackingDisabled: true });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const pagePath = (body.path || '/').split('?')[0];
|
||||
const ip = getClientIp(req);
|
||||
const { success, resetAt } = checkRateLimit(`analytics:${ip}`, ANALYTICS_RPM);
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfterSeconds(resetAt)), 'Cache-Control': 'no-store' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const rawPath = typeof body?.path === 'string' ? body.path : '/';
|
||||
const pagePath = (rawPath.split('?')[0].slice(0, MAX_PATH_LENGTH) || '/') as string;
|
||||
const userAgent = req.headers.get('user-agent') || '';
|
||||
const isMobile = /mobile|iphone|ipad|android/i.test(userAgent);
|
||||
const deviceType = isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
const dateKey = new Date().toISOString().split('T')[0];
|
||||
|
||||
const run = writeQueue.then(async () => {
|
||||
const data = await getAnalytics();
|
||||
|
||||
data.summary.totalViews = (data.summary.totalViews || 0) + 1;
|
||||
@@ -64,10 +107,19 @@ export async function POST(req: Request) {
|
||||
|
||||
const dayObj = data.days[dateKey];
|
||||
dayObj.pageviews = (dayObj.pageviews || 0) + 1;
|
||||
dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1;
|
||||
dayObj.devices[deviceType] = (dayObj.devices[deviceType] || 0) + 1;
|
||||
|
||||
// An already-tracked path may still increment past the cap; only a
|
||||
// brand-new key is refused once the day is full.
|
||||
if (dayObj.pages[pagePath] !== undefined || Object.keys(dayObj.pages).length < MAX_PAGES_PER_DAY) {
|
||||
dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1;
|
||||
}
|
||||
|
||||
await saveAnalytics(data);
|
||||
});
|
||||
writeQueue = run.catch(() => {});
|
||||
await run;
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to record tracking' }, { status: 500 });
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function POST(request: NextRequest) {
|
||||
const hero = albums.length ? await fetchAlbumCovers(apiBase, apiKey, albums) : [];
|
||||
|
||||
try {
|
||||
await completeInstall({
|
||||
const { galleryWritten, settingsWritten } = await completeInstall({
|
||||
hero,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
@@ -115,7 +115,11 @@ export async function POST(request: NextRequest) {
|
||||
immich.invalidateAll();
|
||||
revalidatePath('/', 'layout');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
// completeInstall never overwrites a gallery.yaml or settings.yaml that
|
||||
// was already there (#627) — worth telling the operator when that's what
|
||||
// just happened, since it means the wizard they walked through did less
|
||||
// than it looked like.
|
||||
return NextResponse.json({ success: true, galleryWritten, settingsWritten });
|
||||
} catch (err) {
|
||||
console.error('[Install] Failed to write configuration files:', err);
|
||||
return NextResponse.json({ error: 'Failed to write configuration files' }, { status: 500 });
|
||||
|
||||
@@ -103,6 +103,26 @@
|
||||
border-bottom-color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* The links (Home, subpages, standalone albums, About, Map) live inside
|
||||
MobileNav's wrapper, but `display: contents` erases the wrapper's own box
|
||||
above the mobile breakpoint — the links become direct flex items of
|
||||
`.header__nav`, so desktop keeps the exact layout it always had. */
|
||||
.header__nav-links {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Hidden above the mobile breakpoint — MobileNav renders it unconditionally,
|
||||
but only the CSS decides when it is reachable, same as `.header__wordmark`. */
|
||||
.header__menu-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header__menu-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Main Content ─────────────────────────────────── */
|
||||
.main {
|
||||
max-width: var(--max-width);
|
||||
@@ -1918,6 +1938,67 @@
|
||||
padding: 32px 20px 80px;
|
||||
}
|
||||
|
||||
/* A proper menu replaces the horizontal scroll below this width — see
|
||||
`.header__nav-links` / `.header__menu-btn` above and MobileNav (#590). */
|
||||
.header__menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header__nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* `.header` is the nearest positioned ancestor (it's `position: fixed`),
|
||||
so `top: 100%` sits the panel right under it regardless of the actual
|
||||
header height, which differs by preset (plain padding vs. studio-modern's
|
||||
fixed `--header-height`). `left: 50%` + `width: 100vw` + negative margin
|
||||
breaks the panel out to the full viewport width regardless of `.header`'s
|
||||
own horizontal padding, which also differs by preset (`--sm-gutter` for
|
||||
studio-modern vs. a fixed 24px elsewhere). */
|
||||
.header__nav-links[data-open='true'] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
width: 100vw;
|
||||
margin-left: -50vw;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
background: rgba(26, 26, 26, 0.98);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
[data-theme='light'] .header__nav-links[data-open='true'] {
|
||||
background: rgba(245, 245, 240, 0.98);
|
||||
}
|
||||
|
||||
.header__nav-links[data-open='true'] .header__nav-link,
|
||||
.header__nav-links[data-open='true'] .header__nav-link--external {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Covers the full viewport, including the area behind the header — the
|
||||
header's own `z-index: 100` keeps it crisp above the dim, so this does
|
||||
not need to know the header's height either. */
|
||||
.header__nav-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.header__nav-link {
|
||||
padding: 14px 10px;
|
||||
font-size: 0.6rem;
|
||||
|
||||
@@ -63,6 +63,7 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installError, setInstallError] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
const [galleryKept, setGalleryKept] = useState(false);
|
||||
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
|
||||
@@ -131,6 +132,10 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
|
||||
setInstallError(data.error || 'Install failed');
|
||||
return;
|
||||
}
|
||||
// The wizard never overwrites a gallery.yaml that was already there
|
||||
// (#627) — worth surfacing, since the album picks and site settings
|
||||
// above did not end up in the file that was just kept.
|
||||
setGalleryKept(data.galleryWritten === false);
|
||||
setDone(true);
|
||||
} catch {
|
||||
setInstallError('Could not reach the install service');
|
||||
@@ -148,6 +153,12 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
|
||||
<span className="install-card__kicker">Immich Folio</span>
|
||||
<h1>Install complete</h1>
|
||||
<p>Your portfolio is ready. Add or rearrange albums any time from the admin panel.</p>
|
||||
{galleryKept && (
|
||||
<p className="install-notice">
|
||||
A gallery.yaml already existed in content/, so it was left untouched — the albums
|
||||
picked above were not written. Use the admin panel to change it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* install-body carries the card's padding — every other step
|
||||
renders inside it, and skipping it here left the buttons
|
||||
|
||||
@@ -46,6 +46,17 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Existing gallery.yaml kept as-is rather than replaced (#627) — worth more
|
||||
than the muted paragraph color the completion message above it uses. */
|
||||
.install-card__header p.install-notice {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-left: 3px solid var(--admin-accent);
|
||||
border-radius: 6px;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
/* ── Step indicator ──────────────────────────────────────────── */
|
||||
|
||||
.install-steps {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { Metadata } from 'next';
|
||||
import { readJournalEntry } from '@/lib/admin/journal-service';
|
||||
import { readJournalEntry, listJournalEntries } from '@/lib/admin/journal-service';
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import { isAuthenticated } from '@/lib/auth';
|
||||
import { journalNeighbours } from '@/lib/journalNav';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import {
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { EssayView } from '@/app/[...path]/EssayView';
|
||||
import { JournalNav } from '@/components/JournalNav';
|
||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import type { ImmichAsset } from '@/lib/immich';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
@@ -36,10 +39,28 @@ export async function generateMetadata({ params }: JournalDetailPageProps): Prom
|
||||
if (!entry) return { title: t.journal.notFound };
|
||||
|
||||
const { frontmatter } = entry.parsed;
|
||||
const title = frontmatter.title || slug;
|
||||
const description = frontmatter.subtitle || t.journal.entryDescription;
|
||||
|
||||
const ogImages = frontmatter.coverAssetId
|
||||
// generateMetadata runs unconditionally, ahead of the page body's own
|
||||
// draft/password gate below — without this check a draft's or a locked
|
||||
// entry's real title, subtitle and cover image reached the <head> of a
|
||||
// 200 response no authentication was ever asked for (GHSA-fvgv-97g3-wjr7).
|
||||
const isAuthedAdmin = await isAdminAuthenticated();
|
||||
const blocked =
|
||||
(frontmatter.draft && !isAuthedAdmin) ||
|
||||
(!!frontmatter.password &&
|
||||
!isJournalAuthenticated(
|
||||
slug,
|
||||
frontmatter.password,
|
||||
(await cookies()).get(`lb_auth_journal_${slug}`)?.value,
|
||||
));
|
||||
|
||||
const title = blocked ? t.journal.title : frontmatter.title || slug;
|
||||
const description = blocked
|
||||
? t.journal.description
|
||||
: frontmatter.subtitle || t.journal.entryDescription;
|
||||
|
||||
const ogImages =
|
||||
!blocked && frontmatter.coverAssetId
|
||||
? [{ url: imageUrl(frontmatter.coverAssetId, 'preview') }]
|
||||
: [];
|
||||
|
||||
@@ -111,6 +132,26 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
}
|
||||
}
|
||||
|
||||
// Prev/next through the other entries a visitor may actually see (#591) —
|
||||
// mirrors AlbumNav's rule (#483) of drawing neighbours from the surrounding
|
||||
// list rather than a separate order of its own. A draft or a
|
||||
// password-protected entry the visitor has not unlocked must not appear
|
||||
// here even by name, the same rule the index and this entry's own gate
|
||||
// already enforce (GHSA-fvgv-97g3-wjr7).
|
||||
const journalCookies = await cookies();
|
||||
const allEntries = await listJournalEntries();
|
||||
const visibleEntries = isAuthedAdmin
|
||||
? allEntries
|
||||
: allEntries.filter((e) => {
|
||||
if (e.frontmatter.draft) return false;
|
||||
if (!e.frontmatter.password) return true;
|
||||
return isAuthenticated(e.slug, (name) => journalCookies.get(name)?.value, 'journal');
|
||||
});
|
||||
const nav = journalNeighbours(
|
||||
visibleEntries.map((e) => ({ slug: e.slug, title: e.frontmatter.title || e.slug })),
|
||||
slug,
|
||||
);
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Fetch all referenced assets from Immich
|
||||
@@ -151,8 +192,15 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
|
||||
|
||||
const essayForClient: ParsedJournal = {
|
||||
// Named fields, not a spread of `frontmatter`: EssayView reads only
|
||||
// title, subtitle, coverAssetId, author and date, but a spread put the
|
||||
// stored password — plaintext or a scrypt hash — into every visitor's
|
||||
// RSC payload the moment they unlocked the entry once (GHSA-fvgv-97g3-wjr7).
|
||||
frontmatter: {
|
||||
...frontmatter,
|
||||
title: frontmatter.title,
|
||||
subtitle: frontmatter.subtitle,
|
||||
author: frontmatter.author,
|
||||
date: frontmatter.date,
|
||||
coverAssetId: frontmatter.coverAssetId ? toToken(frontmatter.coverAssetId) : undefined,
|
||||
},
|
||||
blocks: blocks.map((block) => {
|
||||
@@ -205,6 +253,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
subtitle={frontmatter.subtitle}
|
||||
watermark={config.watermark}
|
||||
/>
|
||||
<JournalNav {...nav} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import { type JournalEntrySummary } from '@/lib/journal';
|
||||
import { listJournalEntries } from '@/lib/admin/journal-service';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import { isAuthenticated } from '@/lib/auth';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
@@ -29,10 +31,19 @@ export default async function JournalIndexPage() {
|
||||
const isAuthedAdmin = await isAdminAuthenticated();
|
||||
const allEntries = await listJournalEntries();
|
||||
|
||||
// Non-admins only see published entries
|
||||
// Non-admins only see published entries, and only a password-protected one
|
||||
// once they have actually unlocked it — the card otherwise names it, shows
|
||||
// its cover and quotes its excerpt to every visitor regardless of the
|
||||
// password on its body (GHSA-fvgv-97g3-wjr7).
|
||||
const cookieStore = isAuthedAdmin ? null : await cookies();
|
||||
const getCookie = (name: string) => cookieStore?.get(name)?.value;
|
||||
const visibleEntries = isAuthedAdmin
|
||||
? allEntries
|
||||
: allEntries.filter((e) => !e.frontmatter.draft);
|
||||
: allEntries.filter((e) => {
|
||||
if (e.frontmatter.draft) return false;
|
||||
if (!e.frontmatter.password) return true;
|
||||
return isAuthenticated(e.slug, getCookie, 'journal');
|
||||
});
|
||||
|
||||
// Fetch cover image placeholders and URLs
|
||||
const enrichedEntries: EnrichedJournalEntry[] = await Promise.all(
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import Link from 'next/link';
|
||||
import './globals.css';
|
||||
import { NavLink } from '@/components/NavLink';
|
||||
import { SubpageNav } from '@/components/SubpageNav';
|
||||
import { MobileNav } from '@/components/MobileNav';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { ScrollToTop } from '@/components/ScrollToTop';
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -175,20 +176,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<span className="header__wordmark" aria-hidden="true">
|
||||
{config.siteTitle}
|
||||
</span>
|
||||
<Link href="/" className="header__nav-link">
|
||||
{t.nav.home}
|
||||
</Link>
|
||||
<MobileNav>
|
||||
<NavLink href="/">{t.nav.home}</NavLink>
|
||||
<SubpageNav />
|
||||
{config.aboutEnabled && (
|
||||
<Link href="/about" className="header__nav-link">
|
||||
{t.nav.about}
|
||||
</Link>
|
||||
)}
|
||||
{config.map && (
|
||||
<Link href="/map" className="header__nav-link">
|
||||
{t.nav.map}
|
||||
</Link>
|
||||
)}
|
||||
{config.aboutEnabled && <NavLink href="/about">{t.nav.about}</NavLink>}
|
||||
{config.map && <NavLink href="/map">{t.nav.map}</NavLink>}
|
||||
</MobileNav>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
66
components/JournalNav.tsx
Normal file
66
components/JournalNav.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* JournalNav — the "← previous entry / next entry →" pair at the foot of a
|
||||
* journal entry, mirroring AlbumNav (#483) for the journal (#591).
|
||||
*
|
||||
* Renders nothing when there is no neighbour in either direction, so a lone
|
||||
* entry keeps its quiet ending.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { getServerDictionary } from '@/lib/i18n/server';
|
||||
import type { JournalNavPair } from '@/lib/journalNav';
|
||||
|
||||
function Chevron({ direction }: { direction: 'prev' | 'next' }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points={direction === 'prev' ? '15 18 9 12 15 6' : '9 18 15 12 9 6'} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function JournalNav({ prev, next }: JournalNavPair) {
|
||||
const t = getServerDictionary();
|
||||
if (!prev && !next) return null;
|
||||
|
||||
return (
|
||||
<nav className="album-nav" aria-label={t.common.entryNavAria}>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={prev.href}
|
||||
className="album-nav__link album-nav__link--prev"
|
||||
aria-label={t.common.prevEntryAria(prev.title)}
|
||||
>
|
||||
<Chevron direction="prev" />
|
||||
<span className="album-nav__text">
|
||||
<span className="album-nav__kicker">{t.common.prevEntry}</span>
|
||||
<span className="album-nav__name">{prev.title}</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
/* Holds the next link against the right edge when there is no previous. */
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
{next && (
|
||||
<Link
|
||||
href={next.href}
|
||||
className="album-nav__link album-nav__link--next"
|
||||
aria-label={t.common.nextEntryAria(next.title)}
|
||||
>
|
||||
<span className="album-nav__text">
|
||||
<span className="album-nav__kicker">{t.common.nextEntry}</span>
|
||||
<span className="album-nav__name">{next.title}</span>
|
||||
</span>
|
||||
<Chevron direction="next" />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -131,19 +131,20 @@ export function Lightbox({
|
||||
/**
|
||||
* The deep link to the photo on screen.
|
||||
*
|
||||
* Built from the current index rather than read back from `location.hash`,
|
||||
* so it does not depend on the grid's hash-sync effect having run yet. The
|
||||
* `#photo-N` form is the permalink the grid already writes and restores;
|
||||
* this only supplies the affordance to copy it (#478).
|
||||
* Built from `current.id` — the asset's own stable token, the same one the
|
||||
* grid already carries on every `PhotoItem` — rather than read back from
|
||||
* `location.search`, so it does not depend on the grid's query-sync effect
|
||||
* having run yet. This only supplies the affordance to copy it (#478); the
|
||||
* `?photo=<assetId>` form itself is what the grid writes and restores.
|
||||
*
|
||||
* The link is positional, as `gallery.yaml.example` documents: reordering an
|
||||
* album moves where a shared link lands. That is a property of the existing
|
||||
* permalink, not of the button.
|
||||
* Addressed by the photo, not a position: reordering the album, or
|
||||
* deleting a different photo, no longer repoints an already-shared link
|
||||
* (#588).
|
||||
*/
|
||||
const permalink = useCallback(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return buildPhotoPermalink(window.location, currentIndex);
|
||||
}, [currentIndex]);
|
||||
if (typeof window === 'undefined' || !current) return '';
|
||||
return buildPhotoPermalink(window.location, current.id);
|
||||
}, [current]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
const url = permalink();
|
||||
|
||||
98
components/MobileNav.tsx
Normal file
98
components/MobileNav.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useDictionary } from './I18nProvider';
|
||||
|
||||
/**
|
||||
* MobileNav — hamburger button + dropdown panel for the header links.
|
||||
*
|
||||
* `children` — Home, subpages, standalone albums, About, Map — is the exact
|
||||
* same server-rendered markup the desktop header already used. It is never
|
||||
* duplicated: `.header__nav-links` is `display: contents` above the mobile
|
||||
* breakpoint, so on desktop this component is invisible and the links sit
|
||||
* directly in `.header__nav`'s flex row exactly as before. Below the
|
||||
* breakpoint the same markup becomes the dropdown panel's content, toggled
|
||||
* by the `data-open` attribute rather than by mounting/unmounting (#590).
|
||||
*
|
||||
* The header used to scroll sideways with the scrollbar hidden on narrow
|
||||
* viewports, so a visitor with more subpages than fit the width had no sign
|
||||
* that any were missing.
|
||||
*/
|
||||
export function MobileNav({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const t = useDictionary();
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
// The root layout persists across navigations, so without this the panel
|
||||
// would still be open on the page a visitor just tapped through to. Adjusted
|
||||
// during render rather than in an effect, per React's guidance for state
|
||||
// that only needs to change alongside a prop — it resolves in this render
|
||||
// pass instead of triggering an extra one.
|
||||
const [prevPathname, setPrevPathname] = useState(pathname);
|
||||
if (pathname !== prevPathname) {
|
||||
setPrevPathname(pathname);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="header__menu-btn"
|
||||
aria-expanded={open}
|
||||
aria-controls="header-nav-panel"
|
||||
aria-label={open ? t.nav.closeMenu : t.nav.openMenu}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{open && <div className="header__nav-backdrop" onClick={close} aria-hidden="true" />}
|
||||
<div id="header-nav-panel" className="header__nav-links" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
components/NavLink.tsx
Normal file
47
components/NavLink.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* A header link that knows whether it is the page you are on.
|
||||
*
|
||||
* `.header__nav-link.active` has been styled in globals.css since the header
|
||||
* was written, but nothing ever applied the class — the nav looked like it
|
||||
* should tell you where you are, and did not. `aria-current` was missing with
|
||||
* it, so screen-reader users had no equivalent either.
|
||||
*
|
||||
* Matching: exact for `/`, and prefix-with-separator for everything else, so an
|
||||
* album at `/reisen/island` marks its subpage `/reisen` without `/reise` also
|
||||
* matching `/reisen`. External links are rendered by the caller as plain
|
||||
* anchors and never take part.
|
||||
*/
|
||||
export function NavLink({
|
||||
href,
|
||||
children,
|
||||
className = 'header__nav-link',
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active = isActivePath(pathname, href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={active ? `${className} active` : className}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Exported for tests: whether `href` is the section `pathname` sits in. */
|
||||
export function isActivePath(pathname: string | null, href: string): boolean {
|
||||
if (!pathname) return false;
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* for all subpages and standalone albums in the header.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { NavLink } from './NavLink';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { listJournalEntries } from '@/lib/admin/journal-service';
|
||||
@@ -25,19 +25,15 @@ export async function SubpageNav() {
|
||||
return (
|
||||
<>
|
||||
{subpages.map((sp) => (
|
||||
<Link key={sp.slug} href={`/${sp.slug}`} className="header__nav-link">
|
||||
<NavLink key={sp.slug} href={`/${sp.slug}`}>
|
||||
{sp.name}
|
||||
</Link>
|
||||
</NavLink>
|
||||
))}
|
||||
{hasPublicJournal && (
|
||||
<Link href="/journal" className="header__nav-link">
|
||||
{t.nav.journal}
|
||||
</Link>
|
||||
)}
|
||||
{hasPublicJournal && <NavLink href="/journal">{t.nav.journal}</NavLink>}
|
||||
{standaloneAlbums.map((album) => (
|
||||
<Link key={album.id} href={`/${album.slug}`} className="header__nav-link">
|
||||
<NavLink key={album.id} href={`/${album.slug}`}>
|
||||
{album.albumName}
|
||||
</Link>
|
||||
</NavLink>
|
||||
))}
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
|
||||
105
components/__tests__/NavLink.test.tsx
Normal file
105
components/__tests__/NavLink.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { NavLink } from '../NavLink';
|
||||
|
||||
/**
|
||||
* isActivePath is covered on its own in lib/__tests__/navActive.test.ts. What
|
||||
* this adds is the part that was never verified: that the decision actually
|
||||
* reaches the markup — the class the CSS has been waiting for since the header
|
||||
* was written, and the aria-current that had no equivalent anywhere.
|
||||
*
|
||||
* The routes below are the ones the reference site really serves.
|
||||
*/
|
||||
|
||||
const mockPathname = vi.hoisted(() => ({ value: '/' }));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname.value,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const at = (pathname: string) => {
|
||||
mockPathname.value = pathname;
|
||||
};
|
||||
|
||||
describe('NavLink', () => {
|
||||
it('marks the current page in both the class and aria-current', () => {
|
||||
at('/deutschland');
|
||||
render(<NavLink href="/deutschland">Deutschland</NavLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Deutschland' });
|
||||
expect(link.className).toContain('active');
|
||||
expect(link.getAttribute('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('leaves a link that is not current untouched', () => {
|
||||
at('/polen');
|
||||
render(<NavLink href="/deutschland">Deutschland</NavLink>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Deutschland' });
|
||||
expect(link.className).not.toContain('active');
|
||||
// Absent, not "false": aria-current="false" is a value screen readers
|
||||
// announce differently from the attribute simply not being there.
|
||||
expect(link.getAttribute('aria-current')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the base class alongside the active one, so the link still looks like a link', () => {
|
||||
at('/map');
|
||||
render(<NavLink href="/map">Karte</NavLink>);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Karte' }).className).toContain('header__nav-link');
|
||||
});
|
||||
|
||||
it('marks the subpage while viewing one of its albums', () => {
|
||||
at('/deutschland/kloster-chorin');
|
||||
render(
|
||||
<>
|
||||
<NavLink href="/">Start</NavLink>
|
||||
<NavLink href="/deutschland">Deutschland</NavLink>
|
||||
<NavLink href="/polen">Polen</NavLink>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Deutschland' }).className).toContain('active');
|
||||
expect(screen.getByRole('link', { name: 'Start' }).className).not.toContain('active');
|
||||
expect(screen.getByRole('link', { name: 'Polen' }).className).not.toContain('active');
|
||||
});
|
||||
|
||||
it('marks exactly one link across a full header', () => {
|
||||
at('/deutschland/an-der-ostsee');
|
||||
render(
|
||||
<>
|
||||
<NavLink href="/">Start</NavLink>
|
||||
<NavLink href="/deutschland">Deutschland</NavLink>
|
||||
<NavLink href="/polen">Polen</NavLink>
|
||||
<NavLink href="/japan">Japan</NavLink>
|
||||
<NavLink href="/south-korea">south-korea</NavLink>
|
||||
<NavLink href="/about">Über mich</NavLink>
|
||||
<NavLink href="/map">Karte</NavLink>
|
||||
</>,
|
||||
);
|
||||
|
||||
const marked = screen
|
||||
.getAllByRole('link')
|
||||
.filter((el) => el.getAttribute('aria-current') === 'page');
|
||||
|
||||
expect(marked).toHaveLength(1);
|
||||
expect(marked[0].textContent).toBe('Deutschland');
|
||||
});
|
||||
|
||||
it('marks nothing on a page outside the navigation', () => {
|
||||
at('/impressum');
|
||||
render(
|
||||
<>
|
||||
<NavLink href="/">Start</NavLink>
|
||||
<NavLink href="/about">Über mich</NavLink>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('link').filter((el) => el.getAttribute('aria-current') === 'page'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -35,11 +35,13 @@ The [setup wizard](../README.md#first-run-setup) can set the password instead, s
|
||||
Each area has its own URL, so a section can be bookmarked and the browser's back button works as expected:
|
||||
|
||||
| Tab | URL | Writes to |
|
||||
| ------------- | ------------------ | ------------------------------------------- |
|
||||
| --------------- | -------------------- | ------------------------------------------- |
|
||||
| **Pages** | `/admin/pages` | `content/gallery.yaml` |
|
||||
| **Journal** | `/admin/journal` | `content/journal/<slug>.md` |
|
||||
| **Settings** | `/admin/settings` | `content/settings.yaml`, `content/about.md` |
|
||||
| **Analytics** | `/admin/analytics` | nothing — read-only |
|
||||
| **Diagnostics** | `/admin/diagnostics` | nothing — read-only |
|
||||
| **Help** | `/admin/help` | nothing — reference only |
|
||||
|
||||
Unsaved changes raise a save bar pinned to the bottom of the viewport, so the Save button is reachable without scrolling back up. Saving applies immediately — no server restart.
|
||||
|
||||
@@ -206,7 +208,7 @@ This is useful after making external changes to config files or when Immich data
|
||||
|
||||
The status badge in the header opens a short summary: whether Immich answers, whether `gallery.yaml` and `settings.yaml` parse, the number of cached entries, and the backup count with the timestamp of the most recent one. `settings.yaml` is optional, so its absence is not a fault — only a file that exists and cannot be parsed counts as invalid. A check that could not run (an expired session, for example) is reported as such rather than as an outage.
|
||||
|
||||
Its **Diagnostics** button leads to the full page at `/admin/diagnostics`. Anything that wants a look is listed first; the checks that passed follow, grouped into connection, security and content. Each finding that wants a look links to where it is fixed — a finding about one album opens the subpage it is published on (or, for a standalone album, the album itself). The page also manages backups and clears the cache, and **Copy report** puts the findings on the clipboard as Markdown, ready for a GitHub issue.
|
||||
The **Diagnostics** tab in the top bar leads to the full page at `/admin/diagnostics`. Anything that wants a look is listed first; the checks that passed follow, grouped into connection, security and content. Each finding that wants a look links to where it is fixed — a finding about one album opens the subpage it is published on (or, for a standalone album, the album itself). The page also manages backups and clears the cache, and **Copy report** puts the findings on the clipboard as Markdown, ready for a GitHub issue.
|
||||
|
||||
It also reports **alt text**. Folio has no alt-text field of its own: the Immich description of a photo becomes its alt text, as long as **Photo Description** is switched on under Settings → General. The page counts the published photos that have no description and lists them by album, each linking to the photo in Immich. Add the description there and press **Reload**. The links use Immich's own **External domain** (Administration → Settings → Server in Immich) when it is set. Otherwise they fall back to the address Folio reaches Immich at, which is often an internal Docker name your browser cannot open. Missing alt text does not colour the status badge — most libraries have some, and a badge that is never green stops being read.
|
||||
|
||||
|
||||
83
e2e/photo-permalink.spec.ts
Normal file
83
e2e/photo-permalink.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* `#photo-N` named a position in the album, not a photo — reordering the
|
||||
* album (or deleting an earlier photo) silently repointed every link already
|
||||
* shared. `?photo=<assetId>` addresses the photo itself, and — unlike a hash
|
||||
* — reaches the server, so a shared link also gets an OG preview of the
|
||||
* actual photo rather than the album's generic card (#588).
|
||||
*
|
||||
* These do not reorder the album against a live Immich (that would mutate
|
||||
* real content this test runs against); instead they pin the property a
|
||||
* reorder relies on — the URL identifies the *asset*, so opening it lands on
|
||||
* the same photo no matter what position that asset currently occupies.
|
||||
*/
|
||||
test.describe('Photo permalinks', () => {
|
||||
const GRID_URL = '/deutschland/kloster-chorin';
|
||||
|
||||
test('opening a photo writes a ?photo= URL, not a #photo-N hash', async ({ page }) => {
|
||||
await page.goto(GRID_URL);
|
||||
const item = page.locator('.photo-grid .photo-grid__item').first();
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
await item.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/[?&]photo=/);
|
||||
expect(new URL(page.url()).hash).toBe('');
|
||||
});
|
||||
|
||||
test('navigating directly to a ?photo= link opens that photo', async ({ page }) => {
|
||||
await page.goto(GRID_URL);
|
||||
const items = page.locator('.photo-grid .photo-grid__item');
|
||||
await items.nth(1).scrollIntoViewIfNeeded();
|
||||
await items.nth(1).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const permalink = page.url();
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Reopening the exact link — as a recipient would, with no prior
|
||||
// navigation history — must land on the same photo the link named.
|
||||
await page.goto(permalink);
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page).toHaveURL(permalink);
|
||||
});
|
||||
|
||||
test('a legacy #photo-N link still opens the lightbox (backward compatibility)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`${GRID_URL}#photo-1`);
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a ?photo= link renders an OG preview of the photo itself', async ({ page }) => {
|
||||
await page.goto(GRID_URL);
|
||||
const item = page.locator('.photo-grid .photo-grid__item').first();
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
await item.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const permalink = page.url();
|
||||
await page.goto(permalink);
|
||||
|
||||
// The image itself, not the generated title/subtitle text card the album
|
||||
// page uses — that is the whole point of a photo-specific OG preview.
|
||||
const ogImage = await page.locator('meta[property="og:image"]').getAttribute('content');
|
||||
expect(ogImage).toMatch(/\/api\/image\//);
|
||||
});
|
||||
|
||||
test('the copy-link shortcut copies a ?photo= URL', async ({ page, context }) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.goto(GRID_URL);
|
||||
const item = page.locator('.photo-grid .photo-grid__item').first();
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
await item.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('c');
|
||||
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied).toMatch(/[?&]photo=/);
|
||||
expect(copied).not.toMatch(/#photo-\d+/);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,19 @@ function forgeToken(signingKey: Buffer, payload: object): string {
|
||||
return `${data}.${sig}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The production derivation (lib/admin/auth.ts's getSigningKey()), so a
|
||||
* forged token is rejected only for the reason each test names — the `exp`
|
||||
* or `role` check — rather than failing the HMAC compare first with a stale
|
||||
* key. SHA-256 of `admin:${secret}:${password}` was that key before the
|
||||
* session key moved to scrypt; verifyAdminToken has rejected it ever since,
|
||||
* so a token forged that way never reached the checks below the signature
|
||||
* compare at all (#637).
|
||||
*/
|
||||
function signingKey(secret: string, password: string): Buffer {
|
||||
return crypto.scryptSync(password, `folio-admin-session:${secret}`, 32);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.__TEST_AUTH_SECRET = AUTH_SECRET;
|
||||
process.env.__TEST_ADMIN_PASSWORD = ADMIN_PASSWORD;
|
||||
@@ -135,20 +148,14 @@ describe('admin session tokens', () => {
|
||||
|
||||
it('rejects an expired token', async () => {
|
||||
const { verifyAdminToken } = await loadAuth();
|
||||
const key = crypto
|
||||
.createHash('sha256')
|
||||
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
|
||||
.digest();
|
||||
const key = signingKey(AUTH_SECRET, ADMIN_PASSWORD);
|
||||
const expired = forgeToken(key, { role: 'admin', iat: 0, exp: Date.now() - 1000 });
|
||||
expect(verifyAdminToken(expired)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a validly signed token whose role is not admin', async () => {
|
||||
const { verifyAdminToken } = await loadAuth();
|
||||
const key = crypto
|
||||
.createHash('sha256')
|
||||
.update(`admin:${AUTH_SECRET}:${ADMIN_PASSWORD}`)
|
||||
.digest();
|
||||
const key = signingKey(AUTH_SECRET, ADMIN_PASSWORD);
|
||||
const wrongRole = forgeToken(key, { role: 'viewer', iat: 0, exp: Date.now() + 60_000 });
|
||||
expect(verifyAdminToken(wrongRole)).toBe(false);
|
||||
});
|
||||
|
||||
120
lib/__tests__/atomicWrite.test.ts
Normal file
120
lib/__tests__/atomicWrite.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile, mkdir, stat } from 'fs/promises';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
|
||||
/**
|
||||
* This is the primitive every piece of user-supplied content is written
|
||||
* through — gallery.yaml, settings.yaml, journal entries, about.md, the
|
||||
* favicon. It existed as six hand-written copies that had already drifted in
|
||||
* their temp-file naming and their encoding, so it is worth pinning properly.
|
||||
*/
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-atomic-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const leftovers = async () => (await readdir(root)).filter((f) => f.endsWith('.tmp'));
|
||||
|
||||
describe('atomicWrite', () => {
|
||||
it('writes a new file and leaves no temp file behind', async () => {
|
||||
const target = path.join(root, 'settings.yaml');
|
||||
|
||||
await atomicWrite(target, 'title: Folio\n');
|
||||
|
||||
expect(await readFile(target, 'utf8')).toBe('title: Folio\n');
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces an existing file', async () => {
|
||||
const target = path.join(root, 'gallery.yaml');
|
||||
await writeFile(target, 'old');
|
||||
|
||||
await atomicWrite(target, 'new');
|
||||
|
||||
expect(await readFile(target, 'utf8')).toBe('new');
|
||||
});
|
||||
|
||||
it('writes a Buffer verbatim', async () => {
|
||||
const target = path.join(root, 'favicon.ico');
|
||||
// A byte that is not valid UTF-8, so an accidental string conversion shows.
|
||||
const bytes = Buffer.from([0x00, 0x01, 0xff, 0xfe]);
|
||||
|
||||
await atomicWrite(target, bytes);
|
||||
|
||||
expect(Buffer.compare(await readFile(target), bytes)).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips non-ASCII content as utf8', async () => {
|
||||
const target = path.join(root, 'about.md');
|
||||
const content = '# Über mich\n\nFotografie aus Brandenburg — 日本語 too.\n';
|
||||
|
||||
await atomicWrite(target, content);
|
||||
|
||||
expect(await readFile(target, 'utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('gives concurrent writes to the same path distinct temp files', async () => {
|
||||
const target = path.join(root, 'contended.yaml');
|
||||
|
||||
// The failure this guards against is a shared temp name: one writer's
|
||||
// bytes published under the other's, or a truncated mix of both.
|
||||
await Promise.all(Array.from({ length: 25 }, (_, i) => atomicWrite(target, `writer-${i}\n`)));
|
||||
|
||||
const survivor = await readFile(target, 'utf8');
|
||||
expect(survivor).toMatch(/^writer-\d+\n$/);
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans up the temp file and rethrows when the write cannot land', async () => {
|
||||
// A directory at the target path makes rename fail after the temp file was
|
||||
// written successfully — the exact window the cleanup exists for.
|
||||
const target = path.join(root, 'occupied');
|
||||
await mkdir(target);
|
||||
|
||||
await expect(atomicWrite(target, 'content')).rejects.toThrow();
|
||||
expect(await leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects when the directory does not exist, without creating it', async () => {
|
||||
const target = path.join(root, 'missing-dir', 'file.yaml');
|
||||
|
||||
await expect(atomicWrite(target, 'content')).rejects.toThrow();
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A secret file (install.json's API key and auth secret) used to be
|
||||
* written with the default mode and `chmod`ed to 0600 afterwards — briefly
|
||||
* world-readable between the two. `mode` applies to the temp file before
|
||||
* the rename, so the file is never created with the wrong permissions at
|
||||
* all (GHSA-w293-x8pc-j4cv). Skipped on Windows, where POSIX mode bits do
|
||||
* not apply.
|
||||
*/
|
||||
const describeMode = process.platform === 'win32' ? describe.skip : describe;
|
||||
describeMode('mode option', () => {
|
||||
it('creates a new file with the given mode', async () => {
|
||||
const target = path.join(root, 'install.json');
|
||||
|
||||
await atomicWrite(target, '{}', { mode: 0o600 });
|
||||
|
||||
const mode = (await stat(target)).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it('defaults to the process umask when no mode is given', async () => {
|
||||
const target = path.join(root, 'gallery.yaml');
|
||||
|
||||
await atomicWrite(target, 'albums: []\n');
|
||||
|
||||
const mode = (await stat(target)).mode & 0o777;
|
||||
expect(mode & 0o600).toBe(0o600); // owner read/write, at minimum
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
authenticate,
|
||||
isAuthenticated,
|
||||
isAlbumReachable,
|
||||
withoutLockedAlbums,
|
||||
findSubpageBySlug,
|
||||
} from '@/lib/auth';
|
||||
|
||||
@@ -258,3 +259,30 @@ describe('isAlbumReachable', () => {
|
||||
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The essay layout puts the photographs of several albums on one page and
|
||||
* never passes through an album's own gate, so it has to leave out the ones
|
||||
* that are still locked.
|
||||
*/
|
||||
describe('withoutLockedAlbums', () => {
|
||||
const OPEN = { id: '00000000-0000-0000-0000-000000000002' };
|
||||
const LOCKED = { id: '00000000-0000-0000-0000-000000000005' };
|
||||
|
||||
it('keeps an album that has no password of its own', () => {
|
||||
expect(withoutLockedAlbums([OPEN], () => undefined)).toEqual([OPEN]);
|
||||
});
|
||||
|
||||
it('leaves out an album whose password has not been given', () => {
|
||||
expect(withoutLockedAlbums([OPEN, LOCKED], () => undefined)).toEqual([OPEN]);
|
||||
});
|
||||
|
||||
it('brings it back once it has been unlocked, in the original order', async () => {
|
||||
const header = (await authenticate(LOCKED.id, 'albumpass', 'album'))!;
|
||||
const [name, rest] = header.split('=');
|
||||
const token = rest.split(';')[0];
|
||||
const getCookie = (asked: string) => (asked === name ? token : undefined);
|
||||
|
||||
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
|
||||
});
|
||||
});
|
||||
|
||||
111
lib/__tests__/backup-prune.test.ts
Normal file
111
lib/__tests__/backup-prune.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, mkdir, readdir, rm, writeFile } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Pre-restore snapshots were exempt from pruning while still being listed, so
|
||||
* every restore added a row that was never removed. After a dozen restores the
|
||||
* backup modal showed nothing but pre-restore entries and the ordinary saves
|
||||
* had dropped off the end.
|
||||
*
|
||||
* They are still counted separately from saves — ten saves must not push out
|
||||
* the one snapshot that undoes a restore — but each kind is now capped.
|
||||
*
|
||||
* The service resolves content/ from process.cwd() at import time, so each test
|
||||
* points cwd at a fresh temp directory and re-imports it.
|
||||
*/
|
||||
let root: string;
|
||||
let contentDir: string;
|
||||
let backupDir: string;
|
||||
let service: typeof import('../admin/yaml-service');
|
||||
|
||||
const stamp = (n: number) => `2026-05-${String(n).padStart(2, '0')}T12-00-00-000Z`;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-prune-'));
|
||||
contentDir = path.join(root, 'content');
|
||||
backupDir = path.join(contentDir, '.backups');
|
||||
await mkdir(backupDir, { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
service = await import('../admin/yaml-service');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const backups = async (kind?: 'pre-restore') => {
|
||||
const files = (await readdir(backupDir)).filter((f) => f.startsWith('settings.yaml'));
|
||||
return kind
|
||||
? files.filter((f) => f.includes(kind))
|
||||
: files.filter((f) => !f.includes('pre-restore'));
|
||||
};
|
||||
|
||||
describe('backup pruning', () => {
|
||||
it('caps pre-restore snapshots instead of letting them accumulate', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
expect(await backups('pre-restore')).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('keeps the newest pre-restore snapshots, not the oldest', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
const kept = await backups('pre-restore');
|
||||
expect(kept).toContain(`settings.yaml.${stamp(14)}.pre-restore.bak`);
|
||||
expect(kept).not.toContain(`settings.yaml.${stamp(1)}.pre-restore.bak`);
|
||||
});
|
||||
|
||||
it('does not let saves push out the snapshot that undoes a restore', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(1)}.pre-restore.bak`), 'the undo');
|
||||
for (let i = 2; i <= 13; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.bak`), 'a save');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
// Saves are capped, but the lone pre-restore snapshot survives them.
|
||||
expect(await backups()).toHaveLength(10);
|
||||
expect(await backups('pre-restore')).toEqual([`settings.yaml.${stamp(1)}.pre-restore.bak`]);
|
||||
});
|
||||
|
||||
it('prunes on restore, without waiting for the next save', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: current\n');
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(1)}.bak`), 'title: old\n');
|
||||
for (let i = 2; i <= 13; i++) {
|
||||
await writeFile(path.join(backupDir, `settings.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.restoreBackup(`settings.yaml.${stamp(1)}.bak`);
|
||||
|
||||
// The restore adds one of its own, and the cap holds all the same.
|
||||
expect(await backups('pre-restore')).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('leaves the other file backups alone', async () => {
|
||||
await writeFile(path.join(contentDir, 'settings.yaml'), 'title: Folio\n');
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
await writeFile(path.join(backupDir, `gallery.yaml.${stamp(i)}.pre-restore.bak`), 'old');
|
||||
}
|
||||
|
||||
await service.writeSettingsYaml({ title: 'Folio' });
|
||||
|
||||
const gallery = (await readdir(backupDir)).filter((f) => f.startsWith('gallery.yaml'));
|
||||
expect(gallery).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
@@ -61,17 +61,22 @@ describe('restoreBackup rejects anything that is not a backup it produced', () =
|
||||
describe('restoreBackup accepts the names it writes', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
// The restore itself goes through atomicWrite (writeFile the temp file,
|
||||
// then rename it onto the target) rather than fs.copyFile, so a failure
|
||||
// partway through cannot truncate the live file (#630). fs.copyFile is
|
||||
// still used, but only for the pre-restore safety snapshot.
|
||||
it('restores a normal backup', async () => {
|
||||
await expect(
|
||||
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fs.copyFile).toHaveBeenCalled();
|
||||
expect(fs.copyFile).toHaveBeenCalled(); // pre-restore snapshot
|
||||
expect(fs.rename).toHaveBeenCalled(); // atomic restore write
|
||||
});
|
||||
|
||||
it('restores an about.md backup onto content/about.md', async () => {
|
||||
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
|
||||
|
||||
const calls = vi.mocked(fs.copyFile).mock.calls;
|
||||
const calls = vi.mocked(fs.rename).mock.calls;
|
||||
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
|
||||
});
|
||||
|
||||
@@ -84,8 +89,38 @@ describe('restoreBackup accepts the names it writes', () => {
|
||||
it('derives the destination from the matched name, not a substring search', async () => {
|
||||
await restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.bak');
|
||||
|
||||
// Last copyFile is backup → target; the first is the pre-restore snapshot.
|
||||
const calls = vi.mocked(fs.copyFile).mock.calls;
|
||||
const calls = vi.mocked(fs.rename).mock.calls;
|
||||
expect(String(calls[calls.length - 1][1])).toMatch(/content\/settings\.yaml$/);
|
||||
});
|
||||
|
||||
it('reads the backup before touching the pre-restore snapshot or the live file', async () => {
|
||||
await restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak');
|
||||
|
||||
const readOrder = vi.mocked(fs.readFile).mock.invocationCallOrder[0];
|
||||
const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder[0];
|
||||
expect(readOrder).toBeLessThan(copyOrder);
|
||||
});
|
||||
|
||||
it('aborts without touching the live file when the safety snapshot fails for a reason other than ENOENT', async () => {
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak')).rejects.toThrow(
|
||||
'EACCES',
|
||||
);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(fs.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds when the safety snapshot fails only because there is no current file yet', async () => {
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
restoreBackup('gallery.yaml.2026-05-31T17-30-00-000Z.bak'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fs.rename).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('@/lib/env', () => ({
|
||||
CACHE_TTL: 300,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/env', () => ({
|
||||
RATE_LIMIT_RPM: 120,
|
||||
TRUSTED_PROXY_HOPS: 0,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/env', () => ({
|
||||
RATE_LIMIT_RPM: 120,
|
||||
TRUSTED_PROXY_HOPS: 0,
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secret', () => ({
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('@/lib/env', () => ({
|
||||
import {
|
||||
slugify,
|
||||
albumSlug,
|
||||
findAlbumSlugCollisions,
|
||||
normalizeSlug,
|
||||
buildSubpageGrid,
|
||||
sanitizeNavLinks,
|
||||
@@ -125,6 +126,55 @@ describe('albumSlug', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAlbumSlugCollisions', () => {
|
||||
it('groups albums whose names slugify alike', () => {
|
||||
const collisions = findAlbumSlugCollisions([
|
||||
{ id: 'a', name: 'Japan Trip' },
|
||||
{ id: 'b', name: 'Japan-Trip' },
|
||||
{ id: 'c', name: 'Iceland' },
|
||||
]);
|
||||
expect(collisions).toEqual([
|
||||
{
|
||||
slug: 'japan-trip',
|
||||
albums: [
|
||||
{ id: 'a', name: 'Japan Trip' },
|
||||
{ id: 'b', name: 'Japan-Trip' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports nothing when every slug is distinct', () => {
|
||||
expect(
|
||||
findAlbumSlugCollisions([
|
||||
{ id: 'a', name: 'Japan Trip' },
|
||||
{ id: 'b', name: 'Iceland' },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// albumSlug() falls back to the id for these, which is unique by
|
||||
// construction — two emoji-only albums do not actually collide.
|
||||
it('does not flag two names that both slugify to nothing', () => {
|
||||
expect(
|
||||
findAlbumSlugCollisions([
|
||||
{ id: 'a', name: '🎉' },
|
||||
{ id: 'b', name: '🎊' },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('groups three or more colliding names together', () => {
|
||||
const collisions = findAlbumSlugCollisions([
|
||||
{ id: 'a', name: 'Trip' },
|
||||
{ id: 'b', name: 'TRIP' },
|
||||
{ id: 'c', name: 'trip' },
|
||||
]);
|
||||
expect(collisions).toHaveLength(1);
|
||||
expect(collisions[0].albums).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSlug', () => {
|
||||
// Next hands catch-all route segments over percent-encoded, so this is the
|
||||
// half of #522 that fixing slugify() alone did not cure.
|
||||
|
||||
@@ -252,3 +252,73 @@ describe('deriveGallery accepts valid structures', () => {
|
||||
expect(result.subpages[1].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `slugify` folds diacritics and punctuation, so distinct names collide
|
||||
* easily — "Portfolio 2024" and "Portfolio-2024" both become
|
||||
* "portfolio-2024". Every consumer that resolves a slug takes the first
|
||||
* match, so the second entry was silently unreachable and its settings
|
||||
* resolved from the first (#632).
|
||||
*/
|
||||
describe('deriveGallery rejects colliding slugs', () => {
|
||||
it('rejects two subpages that slugify alike, naming both', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{ name: 'Portfolio 2024', albums: [A] },
|
||||
{ name: 'Portfolio-2024', albums: [B] },
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).toThrow(/"Portfolio 2024".*"Portfolio-2024"|"Portfolio-2024".*"Portfolio 2024"/);
|
||||
});
|
||||
|
||||
it('accepts subpages whose slugs are genuinely distinct', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{ name: 'Portfolio 2024', albums: [A] },
|
||||
{ name: 'Portfolio 2025', albums: [B] },
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects two standalone albums whose title overrides slugify alike', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
albums: [{ [A]: { title: 'Japan Trip' } }, { [B]: { title: 'Japan-Trip' } }],
|
||||
} as unknown as GalleryYaml),
|
||||
).toThrow(/"Japan Trip".*"Japan-Trip"|"Japan-Trip".*"Japan Trip"/);
|
||||
});
|
||||
|
||||
it('rejects two album overrides within the same subpage that slugify alike', () => {
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{
|
||||
name: 'Trips',
|
||||
albums: [{ [A]: { title: 'Japan Trip' } }, { [B]: { title: 'Japan-Trip' } }],
|
||||
},
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).toThrow(/subpage "Trips"/);
|
||||
});
|
||||
|
||||
it('does not flag the same collision across two different subpages', () => {
|
||||
// Different URL prefixes — /trips-a/japan-trip and /trips-b/japan-trip do
|
||||
// not collide with each other, only within one subpage's own route.
|
||||
expect(() =>
|
||||
deriveGallery({
|
||||
subpages: [
|
||||
{ name: 'Trips A', albums: [{ [A]: { title: 'Japan Trip' } }] },
|
||||
{ name: 'Trips B', albums: [{ [B]: { title: 'Japan Trip' } }] },
|
||||
],
|
||||
} as unknown as GalleryYaml),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not flag an album with no title override against another with no override', () => {
|
||||
// Neither album's real name is known without Immich — left to the doctor.
|
||||
expect(() => deriveGallery({ albums: [A, B] } as unknown as GalleryYaml)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
checkProxyHops,
|
||||
countForwardedHops,
|
||||
checkAlbumIds,
|
||||
checkAlbumSlugCollisions,
|
||||
checkAlbumsShared,
|
||||
checkPasswords,
|
||||
checkWritable,
|
||||
@@ -11,6 +12,14 @@ import {
|
||||
worstLevel,
|
||||
} from '../admin/doctor';
|
||||
|
||||
/** A stand-in for lib/config/schema.ts's slugify(), good enough to test the
|
||||
* grouping logic without doctor.ts importing anything (see its own comment). */
|
||||
const testSlug = (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
describe('checkAuthSecret', () => {
|
||||
it('accepts a long secret', () => {
|
||||
expect(checkAuthSecret('a'.repeat(64)).level).toBe('ok');
|
||||
@@ -94,6 +103,19 @@ describe('checkProxyHops', () => {
|
||||
it('accepts one configured hop against one corroborated entry', () => {
|
||||
expect(checkProxyHops(1, 1, true).level).toBe('ok');
|
||||
});
|
||||
|
||||
/**
|
||||
* Cloudflare (1 hop) in front of nginx (1 more, 2 total) with the value set
|
||||
* to 1 used to report "matches the observed chain" — the client IP was
|
||||
* actually read from the Cloudflare edge address, so every visitor behind
|
||||
* that PoP shared one rate-limit bucket (#629).
|
||||
*/
|
||||
it('warns, not passes, when fewer hops are configured than arrived', () => {
|
||||
const f = checkProxyHops(1, 2, true);
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.title).toContain('1');
|
||||
expect(f.title).toContain('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAlbumIds', () => {
|
||||
@@ -142,6 +164,71 @@ describe('checkAlbumsShared', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAlbumSlugCollisions', () => {
|
||||
const known = [
|
||||
{ id: 'a', albumName: 'Japan Trip' },
|
||||
{ id: 'b', albumName: 'Japan-Trip' },
|
||||
{ id: 'c', albumName: 'Iceland' },
|
||||
];
|
||||
|
||||
it('is quiet when nothing collides', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('ok');
|
||||
});
|
||||
|
||||
it('flags two albums in the same group whose Immich names slugify alike', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'b'] }],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('error');
|
||||
expect(f.detail).toContain('Japan Trip');
|
||||
expect(f.detail).toContain('Japan-Trip');
|
||||
expect(f.albumIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not flag the same collision across two different groups', () => {
|
||||
// /trips-a/japan-trip and /trips-b/japan-trip are different URLs.
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[
|
||||
{ context: 'subpage "Trips A"', albumIds: ['a'] },
|
||||
{ context: 'subpage "Trips B"', albumIds: ['b'] },
|
||||
],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('ok');
|
||||
});
|
||||
|
||||
it('prefers a title override over the Immich name', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'c'] }],
|
||||
{ c: 'Japan Trip' }, // overrides "Iceland" — now collides with "a"
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('error');
|
||||
});
|
||||
|
||||
it('says nothing about an id Immich does not know and has no override', () => {
|
||||
const f = checkAlbumSlugCollisions(
|
||||
[{ context: 'gallery.yaml albums', albumIds: ['a', 'ghost'] }],
|
||||
{},
|
||||
known,
|
||||
testSlug,
|
||||
);
|
||||
expect(f.level).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPasswords', () => {
|
||||
it('passes when everything is hashed', () => {
|
||||
expect(checkPasswords([{ label: 'Site', value: 'scrypt:aa:bb' }]).level).toBe('ok');
|
||||
|
||||
116
lib/__tests__/grid-columns-clamp.test.ts
Normal file
116
lib/__tests__/grid-columns-clamp.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
IMMICH_API_URL: 'http://localhost:2283',
|
||||
IMMICH_API_KEY: 'test-key',
|
||||
SITE_TITLE: 'Test Gallery',
|
||||
SITE_SUBTITLE: '',
|
||||
CACHE_TTL: 300,
|
||||
IMMICH_TIMEOUT_MS: 15000,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
TRUSTED_PROXY_HOPS: 0,
|
||||
},
|
||||
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, deriveGallery } from '@/lib/config';
|
||||
import { loadYaml } from '@/lib/config/parser';
|
||||
import type { GalleryYaml } from '@/lib/config/schema';
|
||||
|
||||
const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
function withSettings(settings: unknown) {
|
||||
vi.mocked(loadYaml).mockImplementation((filename: string) => {
|
||||
if (filename === 'gallery.yaml') return { albums: [ALBUM_ID] };
|
||||
if (filename === 'settings.yaml') return settings;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
invalidateConfigCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* `--grid-columns` is emitted unclamped in app/[...path]/page.tsx, so a bad
|
||||
* `columns` reaches `repeat()` invalid and collapses the whole grid to one
|
||||
* column with no error anywhere (#633). The cover grid already clamped
|
||||
* (buildCoverGridVars); the site-wide photo grid did not.
|
||||
*/
|
||||
describe('site-wide grid.columns / grid.gap clamping (#633)', () => {
|
||||
it('clamps a negative columns count to the minimum', () => {
|
||||
withSettings({ grid: { columns: -1 } });
|
||||
expect(getConfig().grid.columns).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range columns count to the maximum', () => {
|
||||
withSettings({ grid: { columns: 99 } });
|
||||
expect(getConfig().grid.columns).toBe(6);
|
||||
});
|
||||
|
||||
it('falls back to the default when columns is not a number', () => {
|
||||
withSettings({ grid: { columns: 'three' as unknown as number } });
|
||||
expect(getConfig().grid.columns).toBe(1); // clamp()'s min, not silently NaN
|
||||
});
|
||||
|
||||
it('keeps a legitimate columns value', () => {
|
||||
withSettings({ grid: { columns: 4 } });
|
||||
expect(getConfig().grid.columns).toBe(4);
|
||||
});
|
||||
|
||||
it('defaults columns to 3 when unset', () => {
|
||||
withSettings({});
|
||||
expect(getConfig().grid.columns).toBe(3);
|
||||
});
|
||||
|
||||
it('clamps gap the same way', () => {
|
||||
withSettings({ grid: { gap: -5 } });
|
||||
expect(getConfig().grid.gap).toBe(0);
|
||||
withSettings({ grid: { gap: 9999 } });
|
||||
invalidateConfigCache();
|
||||
expect(getConfig().grid.gap).toBe(48);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Same guard for a subpage's or an album's own grid override — the merged
|
||||
* value (site-wide default overridden by this) is what actually reaches
|
||||
* `--grid-columns`.
|
||||
*/
|
||||
describe('per-subpage grid override clamping (#633)', () => {
|
||||
it('clamps a negative subpage columns override', () => {
|
||||
const gallery = {
|
||||
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: -1 } }],
|
||||
} as unknown as GalleryYaml;
|
||||
const derived = deriveGallery(gallery);
|
||||
expect(derived.subpages[0].grid?.columns).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range subpage columns override', () => {
|
||||
const gallery = {
|
||||
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: 40 } }],
|
||||
} as unknown as GalleryYaml;
|
||||
const derived = deriveGallery(gallery);
|
||||
expect(derived.subpages[0].grid?.columns).toBe(6);
|
||||
});
|
||||
|
||||
it('keeps a legitimate subpage columns override', () => {
|
||||
const gallery = {
|
||||
subpages: [{ name: 'Trips', albums: [ALBUM_ID], grid: { columns: 2 } }],
|
||||
} as unknown as GalleryYaml;
|
||||
const derived = deriveGallery(gallery);
|
||||
expect(derived.subpages[0].grid?.columns).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,14 @@ describe('resolveImageSize', () => {
|
||||
expect(resolveImageSize(null, null)).toBe('preview');
|
||||
});
|
||||
|
||||
it('honours an explicit size on its own', () => {
|
||||
it('honours an explicit size on its own, up to the preview ceiling', () => {
|
||||
expect(resolveImageSize('thumbnail', null)).toBe('thumbnail');
|
||||
expect(resolveImageSize('original', null)).toBe('original');
|
||||
expect(resolveImageSize('preview', null)).toBe('preview');
|
||||
});
|
||||
|
||||
it('falls back to the width-derived tier when no size is given', () => {
|
||||
expect(resolveImageSize(null, '128')).toBe('thumbnail');
|
||||
expect(resolveImageSize(null, '3840')).toBe('original');
|
||||
expect(resolveImageSize(null, '1000')).toBe('preview');
|
||||
});
|
||||
|
||||
it('ignores an unrecognised size rather than trusting it', () => {
|
||||
@@ -57,4 +57,33 @@ describe('resolveImageSize', () => {
|
||||
expect(resolveImageSize('preview', '640')).toBe('preview');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `/api/image` treats the opaque token as the whole capability check —
|
||||
* holding one means you saw the page it was rendered on. That is right for
|
||||
* a preview, not for the un-downsampled original: only `/api/download`
|
||||
* verifies the album allowlist, `download: true` and every password gate.
|
||||
* `?size=original` or a large `?w=` alone used to reach `original`
|
||||
* unopposed, since the smaller()-of-both-parameters check only ran when
|
||||
* both were present (GHSA-36m4-p39x-9wx8).
|
||||
*/
|
||||
describe('never returns original, regardless of what is asked for', () => {
|
||||
it('caps an explicit ?size=original to preview', () => {
|
||||
expect(resolveImageSize('original', null)).toBe('preview');
|
||||
});
|
||||
|
||||
it('caps a large ?w= alone to preview', () => {
|
||||
expect(resolveImageSize(null, '3840')).toBe('preview');
|
||||
expect(resolveImageSize(null, '1920')).toBe('preview');
|
||||
});
|
||||
|
||||
it('caps the combination of size=original and a large width', () => {
|
||||
expect(resolveImageSize('original', '3840')).toBe('preview');
|
||||
});
|
||||
|
||||
it('still narrows to thumbnail when the width says so', () => {
|
||||
// The cap is a ceiling, not a floor — a small width may still win.
|
||||
expect(resolveImageSize('original', '128')).toBe('thumbnail');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { immich, ImmichUnavailableError } from '../immich';
|
||||
import * as config from '../config';
|
||||
import { cache } from '../cache';
|
||||
@@ -8,6 +8,8 @@ import type { AlbumSortMode } from '../albumSort';
|
||||
// able to change it between calls without rebuilding the whole mock.
|
||||
const albumSortModes: Record<string, AlbumSortMode> = {};
|
||||
const albumManualOrders: Record<string, string[]> = {};
|
||||
// Same for the subpages: the route tests below add one and take it away again.
|
||||
const subpages: { slug: string; albumIds: string[]; enabled?: boolean }[] = [];
|
||||
|
||||
// Mock the config by wrapping it in a factory
|
||||
vi.mock('../config', async () => {
|
||||
@@ -18,8 +20,10 @@ vi.mock('../config', async () => {
|
||||
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
|
||||
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||
albums: ['album-1', 'album-2'],
|
||||
standaloneAlbums: ['album-2', 'album-1'],
|
||||
subpages: [],
|
||||
standaloneAlbums: ['album-2', 'album-1'].filter(
|
||||
(id) => !subpages.some((sp) => sp.albumIds.includes(id)),
|
||||
),
|
||||
subpages,
|
||||
albumOverrides: { 'album-1': 'Override Name' },
|
||||
albumDescriptions: {},
|
||||
albumSortModes,
|
||||
@@ -267,6 +271,53 @@ describe('ImmichClient', () => {
|
||||
const result = await immich.streamVideo('asset-1', 'bytes=0-1023');
|
||||
expect(result?.status).toBe(206);
|
||||
});
|
||||
|
||||
/**
|
||||
* Neither error branch reads the body. Under undici an unconsumed one
|
||||
* keeps its socket out of the pool until GC finalises it, so a proxy
|
||||
* answering every request with an error accumulates orphaned sockets for
|
||||
* as long as the fault lasts (#635).
|
||||
*/
|
||||
const streamResWithBody = (status: number) => {
|
||||
const cancel = vi.fn();
|
||||
return {
|
||||
res: {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
body: { cancel },
|
||||
headers: { get: () => null },
|
||||
},
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
|
||||
it('streamAsset cancels the body on the "gone" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(404);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamAsset('asset-1')).resolves.toBeNull();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('streamAsset cancels the body on the "outage" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(500);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('streamVideo cancels the body on the "gone" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(404);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamVideo('asset-1')).resolves.toBeNull();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('streamVideo cancels the body on the "outage" path', async () => {
|
||||
const { res, cancel } = streamResWithBody(502);
|
||||
mockFetch.mockResolvedValueOnce(res);
|
||||
await expect(immich.streamVideo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// Measured before the fix: 5 sequential lookups of a missing asset produced 5
|
||||
@@ -438,6 +489,66 @@ describe('ImmichClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* config.albums is the union of standalone and subpage albums. A slug has to
|
||||
* be looked up among the albums of the route it arrived on, or a top-level
|
||||
* URL answers for an album whose only route is a subpage — past that
|
||||
* subpage's password.
|
||||
*/
|
||||
describe('getAlbumBySlug() searches the route, not the allowlist', () => {
|
||||
beforeEach(() => {
|
||||
const json = (body: unknown) => ({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => body,
|
||||
});
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search/metadata')) {
|
||||
return json({ assets: { items: [], nextPage: null, total: 0 } });
|
||||
}
|
||||
const one = /\/albums\/(album-[12])/.exec(url);
|
||||
if (one) {
|
||||
const albumName = one[1] === 'album-1' ? 'Client Preview' : 'Open Work';
|
||||
return json({ id: one[1], albumName, assetCount: 0, assets: [], order: 'desc' });
|
||||
}
|
||||
return json([
|
||||
{ id: 'album-1', albumName: 'Client Preview', assetCount: 0 },
|
||||
{ id: 'album-2', albumName: 'Open Work', assetCount: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
subpages.length = 0;
|
||||
});
|
||||
|
||||
it('finds a standalone album at the top level', async () => {
|
||||
const album = await immich.getAlbumBySlug('open-work');
|
||||
expect(album?.id).toBe('album-2');
|
||||
});
|
||||
|
||||
it('does not find a subpage album at the top level', async () => {
|
||||
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
|
||||
|
||||
// album-1 carries the 'Override Name' override, so that is its slug.
|
||||
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
|
||||
expect((await immich.getAlbumBySlug('override-name', 'handover'))?.id).toBe('album-1');
|
||||
});
|
||||
|
||||
it('does not find anything through a disabled subpage', async () => {
|
||||
subpages.push({ slug: 'handover', albumIds: ['album-1'], enabled: false });
|
||||
|
||||
expect(await immich.getAlbumBySlug('override-name', 'handover')).toBeNull();
|
||||
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not find a standalone album through a subpage that does not list it', async () => {
|
||||
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
|
||||
|
||||
expect(await immich.getAlbumBySlug('open-work', 'handover')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStandaloneAlbums()', () => {
|
||||
it('orders albums by gallery.yaml order, not Immich API order', async () => {
|
||||
// The Immich API returns albums in its own (creation/update) order;
|
||||
@@ -760,6 +871,62 @@ describe('stale fallback when Immich is unavailable', () => {
|
||||
await expect(immich.getAlbums()).rejects.toThrow(ImmichUnavailableError);
|
||||
});
|
||||
|
||||
/**
|
||||
* getAlbums(true) used to delete the cache entry before attempting the
|
||||
* fetch. A request with forceFresh that then failed left every later
|
||||
* visitor — not just the one who asked for a refresh — with nothing to
|
||||
* fall back to, for as long as the outage lasted (GHSA-w293-x8pc-j4cv).
|
||||
*/
|
||||
it('forceFresh does not delete the entry a failed refresh could have fallen back to', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => [
|
||||
{
|
||||
id: 'album-1',
|
||||
albumName: 'Original',
|
||||
description: '',
|
||||
albumThumbnailAssetId: null,
|
||||
assetCount: 1,
|
||||
assets: [],
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await immich.getAlbums()).toHaveLength(1);
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const refreshed = await immich.getAlbums(true);
|
||||
|
||||
expect(refreshed).toHaveLength(1);
|
||||
expect(refreshed[0].id).toBe('album-1');
|
||||
});
|
||||
|
||||
it('getAlbum(id, true) has the same guarantee as getAlbums(true)', async () => {
|
||||
// GET /albums/:id and POST /search/metadata run concurrently (Promise.all
|
||||
// in loadAlbum), so they must be routed by URL rather than queued in call
|
||||
// order — same pattern as the getAlbum() describe block below.
|
||||
mockFetch.mockImplementation(async (url: string) => ({
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () =>
|
||||
url.includes('/search/metadata')
|
||||
? { assets: { items: [], nextPage: null, total: 0 } }
|
||||
: { id: 'album-1', albumName: 'Original', assetCount: 0, assets: [], order: 'desc' },
|
||||
}));
|
||||
expect(await immich.getAlbum('album-1')).not.toBeNull();
|
||||
|
||||
mockFetch.mockImplementation(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
const refreshed = await immich.getAlbum('album-1', true);
|
||||
|
||||
expect(refreshed).not.toBeNull();
|
||||
expect(refreshed?.id).toBe('album-1');
|
||||
});
|
||||
|
||||
it('does not swallow a definitive 404 as an outage', async () => {
|
||||
// A missing album must keep reporting missing, not fall back to stale data.
|
||||
mockFetch.mockResolvedValue({
|
||||
@@ -775,4 +942,44 @@ describe('stale fallback when Immich is unavailable', () => {
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await expect(immich.getAlbum('not-allowed')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
// #626: a cached 404 used to come back from the stale fallback as the MISSING
|
||||
// sentinel itself — a truthy object with no fields. Every `if (!asset)` guard
|
||||
// passed on it and the page answered 500 for the length of the outage.
|
||||
const notFound = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { get: () => 'application/json' },
|
||||
};
|
||||
|
||||
it('answers a stale "missing" asset with null during an outage', async () => {
|
||||
mockFetch.mockResolvedValue(notFound);
|
||||
await expect(immich.getAssetInfo('deleted-hero')).resolves.toBeNull();
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(60_001); // past cacheTtl, inside the stale window
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await immich.getAssetInfo('deleted-hero');
|
||||
vi.useRealTimers();
|
||||
|
||||
// toBeNull, not toBeFalsy: the bug was a truthy object, so the precise
|
||||
// assertion is the one that would have failed.
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('answers a stale "missing" album with null during an outage', async () => {
|
||||
mockFetch.mockResolvedValue(notFound);
|
||||
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(60_001);
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await immich.getAlbum('album-1');
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
107
lib/__tests__/immichCache.test.ts
Normal file
107
lib/__tests__/immichCache.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// vi.mock is hoisted above the file, so the stubs have to be too.
|
||||
const { getStale, set } = vi.hoisted(() => ({ getStale: vi.fn(), set: vi.fn() }));
|
||||
|
||||
vi.mock('../cache', () => ({ cache: { getStale, set } }));
|
||||
|
||||
import { MISSING, cacheSet, staleOrMissing, staleOrThrow } from '../immichCache';
|
||||
import { ImmichUnavailableError } from '../immichTransport';
|
||||
|
||||
/**
|
||||
* This policy decides what visitors see while Immich is down, and it used to be
|
||||
* reachable only through HTTP mocks against the client (#610). These are the
|
||||
* tests that became possible once it stood on its own.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('staleOrThrow', () => {
|
||||
it('serves the last known good answer when Immich is unavailable', () => {
|
||||
getStale.mockReturnValue([{ id: 'album-1' }]);
|
||||
|
||||
const result = staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums');
|
||||
|
||||
expect(result).toEqual([{ id: 'album-1' }]);
|
||||
});
|
||||
|
||||
it('rethrows when nothing usable is left in the cache', () => {
|
||||
// Past staleMaxAge the entry is gone, and the outage surfaces as before.
|
||||
getStale.mockReturnValue(null);
|
||||
|
||||
expect(() => staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums')).toThrow(
|
||||
ImmichUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not serve stale data for an error that is not an outage', () => {
|
||||
// A TypeError means we asked the wrong question. Answering it with
|
||||
// yesterday's data would hide a real bug behind a plausible page.
|
||||
getStale.mockReturnValue([{ id: 'album-1' }]);
|
||||
|
||||
expect(() => staleOrThrow('albums', new TypeError('read of undefined'), 'albums')).toThrow(
|
||||
TypeError,
|
||||
);
|
||||
expect(getStale).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says so in the log when it serves something stale', () => {
|
||||
getStale.mockReturnValue(['cached']);
|
||||
|
||||
staleOrThrow('albums', new ImmichUnavailableError('502'), 'album list');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('album list'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the MISSING sentinel (#626)', () => {
|
||||
it('is never cast into real data by staleOrThrow', () => {
|
||||
// The sentinel handed back as an album is a truthy object with no fields;
|
||||
// every `if (!album)` guard passes and the page answers 500. For a key
|
||||
// that should only ever hold data, meeting it means nothing is usable.
|
||||
getStale.mockReturnValue(MISSING);
|
||||
|
||||
expect(() => staleOrThrow('albums', new ImmichUnavailableError('502'), 'albums')).toThrow(
|
||||
ImmichUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('comes back from staleOrMissing as null — the last authoritative answer', () => {
|
||||
getStale.mockReturnValue(MISSING);
|
||||
|
||||
expect(staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toBeNull();
|
||||
});
|
||||
|
||||
it('lets staleOrMissing still serve real stale data', () => {
|
||||
getStale.mockReturnValue({ id: 'asset-x' });
|
||||
|
||||
expect(staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toEqual({
|
||||
id: 'asset-x',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets staleOrMissing throw when nothing is cached', () => {
|
||||
getStale.mockReturnValue(null);
|
||||
|
||||
expect(() => staleOrMissing('asset-x', new ImmichUnavailableError('502'), 'asset x')).toThrow(
|
||||
ImmichUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps staleOrMissing from answering a non-outage error', () => {
|
||||
getStale.mockReturnValue(MISSING);
|
||||
|
||||
expect(() => staleOrMissing('asset-x', new TypeError('bug'), 'asset x')).toThrow(TypeError);
|
||||
expect(getStale).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheSet', () => {
|
||||
it('passes the configured windows through', () => {
|
||||
cacheSet('albums', ['a'], 60_000, 3_600_000);
|
||||
|
||||
expect(set).toHaveBeenCalledWith('albums', ['a'], 60_000, 3_600_000);
|
||||
});
|
||||
});
|
||||
208
lib/__tests__/immichTransport.test.ts
Normal file
208
lib/__tests__/immichTransport.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { requestJson, ImmichUnavailableError, isTimeout } from '../immichTransport';
|
||||
|
||||
/**
|
||||
* The distinction these tests pin is the one that matters to visitors: `null`
|
||||
* means Immich answered and the thing is gone, an ImmichUnavailableError means
|
||||
* Immich could not answer at all. Conflating them once rendered every album URL
|
||||
* as a hard 404 during an outage — including albums that exist.
|
||||
*/
|
||||
|
||||
const opts = {
|
||||
apiUrl: 'http://immich.test/api',
|
||||
apiKey: 'test-key',
|
||||
timeoutMs: 5000,
|
||||
endpoint: '/albums',
|
||||
};
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('requestJson', () => {
|
||||
it('returns the parsed body', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => json([{ id: 'album-1' }])),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).resolves.toEqual([{ id: 'album-1' }]);
|
||||
});
|
||||
|
||||
it('sends the API key and asks for JSON', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await requestJson(opts);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://immich.test/api/albums');
|
||||
expect(init).toBeDefined();
|
||||
expect(init?.method).toBe('GET');
|
||||
expect(init?.headers).toMatchObject({ 'x-api-key': 'test-key', Accept: 'application/json' });
|
||||
});
|
||||
|
||||
it('posts when given a body', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await requestJson({ ...opts, body: { albumId: 'a1' } });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(init?.body).toBe('{"albumId":"a1"}');
|
||||
});
|
||||
|
||||
it('returns null for a resource that is genuinely gone', async () => {
|
||||
for (const status of [404, 410]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status })),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).resolves.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws for a status that means "not right now"', async () => {
|
||||
// A 5xx, a rate limit or a rejected API key are outages, not deletions.
|
||||
for (const status of [401, 429, 500, 502, 503]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status, statusText: 'Nope' })),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the status on the error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status: 503, statusText: 'Unavailable' })),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Neither the "gone" nor the "outage" branch reads the body. Under undici
|
||||
* an unconsumed one keeps its socket out of the pool until GC finalises it,
|
||||
* so a proxy answering every request with an error accumulates orphaned
|
||||
* sockets for as long as the fault lasts (#635).
|
||||
*/
|
||||
it('cancels the body on the "gone" path (404/410)', async () => {
|
||||
for (const status of [404, 410]) {
|
||||
const res = new Response('', { status });
|
||||
const cancelSpy = vi.spyOn(res.body!, 'cancel');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => res),
|
||||
);
|
||||
|
||||
await requestJson(opts);
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the body on the "outage" path (5xx)', async () => {
|
||||
const res = new Response('', { status: 503, statusText: 'Unavailable' });
|
||||
const cancelSpy = vi.spyOn(res.body!, 'cancel');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => res),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
|
||||
expect(cancelSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('cancels the body when the content type is not JSON', async () => {
|
||||
const res = new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
const cancelSpy = vi.spyOn(res.body!, 'cancel');
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => res),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
|
||||
expect(cancelSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('refuses a non-JSON body', async () => {
|
||||
// We always send Accept: application/json, so an HTML body is a gateway
|
||||
// error page, not an answer about the resource.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
|
||||
});
|
||||
|
||||
it('reports a timeout as such, with the budget in the message', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/did not respond within 5000ms/);
|
||||
});
|
||||
|
||||
it('reports an unreachable host separately from a timeout', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/Cannot reach Immich/);
|
||||
});
|
||||
|
||||
it('turns a malformed body into an outage rather than a crash', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response('{ not json', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(requestJson(opts)).rejects.toThrow(/malformed JSON/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTimeout', () => {
|
||||
it('recognises both ways of giving up waiting', () => {
|
||||
expect(isTimeout(Object.assign(new Error(''), { name: 'TimeoutError' }))).toBe(true);
|
||||
expect(isTimeout(Object.assign(new Error(''), { name: 'AbortError' }))).toBe(true);
|
||||
expect(isTimeout(new TypeError('fetch failed'))).toBe(false);
|
||||
expect(isTimeout('not an error')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,13 @@ vi.mock('@/lib/env', () => ({
|
||||
return process.env.INSTALL_CONTENT_DIR;
|
||||
},
|
||||
},
|
||||
normalizeApiUrl: (raw: string) => {
|
||||
try {
|
||||
return new URL(raw).toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
let dir: string;
|
||||
|
||||
@@ -23,6 +23,15 @@ vi.mock('@/lib/env', () => ({
|
||||
return process.env.INSTALL_CONTENT_DIR || '';
|
||||
},
|
||||
},
|
||||
// Real implementation, not a stub: install.ts's own normalisation tests
|
||||
// (#634) rely on this actually stripping trailing slashes.
|
||||
normalizeApiUrl: (raw: string) => {
|
||||
try {
|
||||
return new URL(raw).toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/** Import fresh so the module-level install-file cache cannot leak between cases. */
|
||||
@@ -103,6 +112,45 @@ describe('getInstallCredentials', () => {
|
||||
adminPassword: '',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The environment path already strips a trailing slash (lib/env.ts); the
|
||||
* file path used to store content/install.json's apiUrl completely
|
||||
* unnormalised. `https://host/api/` then reached getConfig() as
|
||||
* `.../api/`, which does not `endsWith('/api')`, so it became
|
||||
* `.../api//api` and every request 404ed (#634). Normalising on every read
|
||||
* means an install.json written before this fix heals without re-running
|
||||
* the wizard.
|
||||
*/
|
||||
describe('heals an unnormalised URL already on disk (#634)', () => {
|
||||
it.each([
|
||||
['trailing slash, no /api', 'https://file-immich:2283/', 'https://file-immich:2283'],
|
||||
[
|
||||
'trailing slash, with /api',
|
||||
'https://file-immich:2283/api/',
|
||||
'https://file-immich:2283/api',
|
||||
],
|
||||
['no trailing slash, already clean', 'https://file-immich:2283', 'https://file-immich:2283'],
|
||||
])('file path: %s', async (_name, stored, expected) => {
|
||||
const { getInstallCredentials } = await loadInstall();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'install.json'),
|
||||
JSON.stringify({ apiUrl: stored, apiKey: 'file-key' }),
|
||||
);
|
||||
|
||||
expect(getInstallCredentials().apiUrl).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['trailing slash, no /api', 'https://env-immich:2283/', 'https://env-immich:2283'],
|
||||
['trailing slash, with /api', 'https://env-immich:2283/api/', 'https://env-immich:2283/api'],
|
||||
])('env path: %s', async (_name, envValue, expected) => {
|
||||
const { getInstallCredentials } = await loadInstall();
|
||||
process.env.__T_INSTALL_URL = envValue;
|
||||
|
||||
expect(getInstallCredentials().apiUrl).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstalled', () => {
|
||||
@@ -168,6 +216,23 @@ describe('completeInstall', () => {
|
||||
expect(getInstallCredentials().authSecret).toBe(creds.authSecret);
|
||||
});
|
||||
|
||||
/**
|
||||
* The route verifies the connection against normalizeApiBase(apiUrl),
|
||||
* which strips a trailing slash, but this used to write input.apiUrl.trim()
|
||||
* unchanged — so a URL that passed the ping test was stored as typed and
|
||||
* every request afterwards 404ed (#634).
|
||||
*/
|
||||
it.each([
|
||||
['trailing slash, no /api', 'https://immich.example/', 'https://immich.example'],
|
||||
['trailing slash, with /api', 'https://immich.example/api/', 'https://immich.example/api'],
|
||||
])('normalises the stored apiUrl: %s', async (_name, typed, expected) => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
await completeInstall({ apiUrl: typed, apiKey: 'api-key-123' });
|
||||
|
||||
const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
|
||||
expect(creds.apiUrl).toBe(expected);
|
||||
});
|
||||
|
||||
it('writes an empty albums list when no albums are selected', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
await completeInstall({
|
||||
@@ -202,6 +267,68 @@ describe('completeInstall', () => {
|
||||
|
||||
expect(isInstalled()).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* isInstalled() is false whenever Immich credentials fail to resolve,
|
||||
* whether or not gallery.yaml exists — a bad IMMICH_API_URL or a corrupt
|
||||
* install.json is enough. Re-running the wizard after that used to replace
|
||||
* every subpage, section, password, assetOrder and per-album override with
|
||||
* a two-key skeleton (#627).
|
||||
*/
|
||||
it('does not overwrite an existing gallery.yaml', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
const galleryPath = path.join(dir, 'gallery.yaml');
|
||||
const existing = 'albums:\n - existing-album\nsubpages:\n - slug: trips\n';
|
||||
fs.writeFileSync(galleryPath, existing);
|
||||
|
||||
const result = await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'api-key-123',
|
||||
albums: [ALBUM_ID],
|
||||
});
|
||||
|
||||
expect(result.galleryWritten).toBe(false);
|
||||
expect(fs.readFileSync(galleryPath, 'utf8')).toBe(existing);
|
||||
});
|
||||
|
||||
it('does not overwrite an existing settings.yaml', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
const settingsPath = path.join(dir, 'settings.yaml');
|
||||
const existing = 'title: "Existing Site"\n';
|
||||
fs.writeFileSync(settingsPath, existing);
|
||||
|
||||
const result = await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'api-key-123',
|
||||
siteTitle: 'New Title',
|
||||
});
|
||||
|
||||
expect(result.settingsWritten).toBe(false);
|
||||
expect(fs.readFileSync(settingsPath, 'utf8')).toBe(existing);
|
||||
});
|
||||
|
||||
it('writes both files on a genuinely fresh install and reports it', async () => {
|
||||
const { completeInstall } = await loadInstall();
|
||||
|
||||
const result = await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'api-key-123',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ galleryWritten: true, settingsWritten: true });
|
||||
});
|
||||
|
||||
it('still writes fresh credentials into install.json when gallery.yaml is kept', async () => {
|
||||
const { completeInstall, getInstallCredentials } = await loadInstall();
|
||||
fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
|
||||
|
||||
await completeInstall({
|
||||
apiUrl: 'https://immich.example',
|
||||
apiKey: 'fresh-key',
|
||||
});
|
||||
|
||||
expect(getInstallCredentials().apiKey).toBe('fresh-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeApiBase', () => {
|
||||
|
||||
@@ -14,12 +14,14 @@ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
|
||||
let root: string;
|
||||
let journalDir: string;
|
||||
let backupDir: string;
|
||||
let essaysDir: string;
|
||||
let service: typeof import('../admin/journal-service');
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-'));
|
||||
journalDir = path.join(root, 'content', 'journal');
|
||||
backupDir = path.join(journalDir, '.backups');
|
||||
essaysDir = path.join(root, 'content', 'essays');
|
||||
await mkdir(journalDir, { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
@@ -62,6 +64,56 @@ describe('deleteJournalEntry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeJournalEntry backup failure (#630)', () => {
|
||||
it('still saves a brand-new entry with no backup', async () => {
|
||||
await expect(service.writeJournalEntry('trip', entry('New'))).resolves.toBeUndefined();
|
||||
await expect(readdir(backupDir).catch(() => [])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('aborts the save when the entry exists but the backup cannot be written', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Old'));
|
||||
// A file where the backup directory should be makes mkdir and copy fail.
|
||||
await writeFile(backupDir, 'not a directory');
|
||||
|
||||
await expect(service.writeJournalEntry('trip', entry('New'))).rejects.toThrow();
|
||||
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Old'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy content/essays/ entries (#631)', () => {
|
||||
it('retires the legacy copy once a legacy entry is edited', async () => {
|
||||
await mkdir(essaysDir, { recursive: true });
|
||||
await writeFile(path.join(essaysDir, 'trip.md'), entry('Old'));
|
||||
|
||||
await service.writeJournalEntry('trip', entry('New'));
|
||||
|
||||
await expect(readdir(essaysDir)).resolves.not.toContain('trip.md');
|
||||
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('New'));
|
||||
});
|
||||
|
||||
it('does not resurrect a legacy entry after it is edited then deleted', async () => {
|
||||
await mkdir(essaysDir, { recursive: true });
|
||||
await writeFile(path.join(essaysDir, 'trip.md'), entry('Old'));
|
||||
|
||||
await service.writeJournalEntry('trip', entry('New'));
|
||||
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
|
||||
|
||||
const entries = await service.listJournalEntries();
|
||||
expect(entries.map((e) => e.slug)).not.toContain('trip');
|
||||
});
|
||||
|
||||
it('deletes a legacy entry that was never edited, from content/essays/', async () => {
|
||||
await mkdir(essaysDir, { recursive: true });
|
||||
await writeFile(path.join(essaysDir, 'trip.md'), entry('Legacy'));
|
||||
|
||||
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
|
||||
|
||||
await expect(readdir(essaysDir)).resolves.not.toContain('trip.md');
|
||||
const entries = await service.listJournalEntries();
|
||||
expect(entries.map((e) => e.slug)).not.toContain('trip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreJournalBackup', () => {
|
||||
it('brings a deleted entry back under its slug', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
|
||||
|
||||
92
lib/__tests__/journal-roundtrip.test.ts
Normal file
92
lib/__tests__/journal-roundtrip.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseJournalMarkdown, serializeJournalMarkdown } from '../journal';
|
||||
|
||||
/**
|
||||
* serializeJournalMarkdown must be the exact inverse of the inline renderer
|
||||
* for the constructs it produces, or a save silently rewrites the file (#628).
|
||||
*/
|
||||
const roundTrip = (source: string) => serializeJournalMarkdown(parseJournalMarkdown(source));
|
||||
|
||||
describe('journal round trip (#628)', () => {
|
||||
it('keeps a link through save', () => {
|
||||
const source = 'Read more at [Immich](https://immich.app) today.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('keeps emphasis inside a quote as markdown, not literal tags', () => {
|
||||
const source = '> **Yes** he said -- Someone';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
expect(once).not.toContain('<strong>');
|
||||
});
|
||||
|
||||
it('keeps emphasis inside a photo caption as markdown', () => {
|
||||
const source = '';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('does not double-encode an apostrophe in a photo-pair caption', () => {
|
||||
const source = "";
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('leaves intraword underscores alone', () => {
|
||||
const source = 'See my_photo_2024.jpg for details.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('still renders underscore emphasis with word boundaries either side', () => {
|
||||
const source = 'This is _emphasised_ text.';
|
||||
const parsed = parseJournalMarkdown(source);
|
||||
expect(parsed.blocks[0]).toMatchObject({
|
||||
type: 'paragraph',
|
||||
html: 'This is <em>emphasised</em> text.',
|
||||
});
|
||||
// `_` and `*` both parse to <em>, so a `_`-emphasised word comes back with
|
||||
// `*` — that normalisation is expected. What must hold is idempotence.
|
||||
const once = roundTrip(source);
|
||||
expect(roundTrip(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('round-trips a link nested inside bold text', () => {
|
||||
const source = '**[Immich](https://immich.app)** is great.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('round-trips bold text nested inside a link label', () => {
|
||||
const source = '[**Immich**](https://immich.app) is great.';
|
||||
const once = roundTrip(source);
|
||||
expect(once).toBe(source);
|
||||
});
|
||||
|
||||
it('is idempotent on a full multi-block entry', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'title: "A trip"',
|
||||
'---',
|
||||
'',
|
||||
'# Heading',
|
||||
'',
|
||||
'Read more at [Immich](https://immich.app), it is **great** and *fun*.',
|
||||
'',
|
||||
'> **Yes** he said -- Someone',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
].join('\n');
|
||||
|
||||
const once = roundTrip(source);
|
||||
const twice = roundTrip(once);
|
||||
expect(twice).toBe(once);
|
||||
expect(once).toContain('[Immich](https://immich.app)');
|
||||
expect(once).toContain("Anna's photos");
|
||||
});
|
||||
});
|
||||
51
lib/__tests__/journalNav.test.ts
Normal file
51
lib/__tests__/journalNav.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { journalNeighbours } from '@/lib/journalNav';
|
||||
|
||||
const entry = (slug: string, title = slug.toUpperCase()) => ({ slug, title });
|
||||
|
||||
const three = [entry('a'), entry('b'), entry('c')];
|
||||
|
||||
describe('journalNeighbours', () => {
|
||||
it('offers both neighbours in the middle of a list', () => {
|
||||
expect(journalNeighbours(three, 'b')).toEqual({
|
||||
prev: { href: '/journal/a', title: 'A' },
|
||||
next: { href: '/journal/c', title: 'C' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the previous link on the first entry', () => {
|
||||
expect(journalNeighbours(three, 'a')).toEqual({ next: { href: '/journal/b', title: 'B' } });
|
||||
});
|
||||
|
||||
it('omits the next link on the last entry — no wrap-around', () => {
|
||||
expect(journalNeighbours(three, 'c')).toEqual({ prev: { href: '/journal/b', title: 'B' } });
|
||||
});
|
||||
|
||||
it('returns nothing for a single-entry list', () => {
|
||||
expect(journalNeighbours([entry('a')], 'a')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns nothing for an empty list', () => {
|
||||
expect(journalNeighbours([], 'a')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns nothing when the entry is not in the list', () => {
|
||||
expect(journalNeighbours(three, 'zzz')).toEqual({});
|
||||
});
|
||||
|
||||
it('follows list order, not slug or title order', () => {
|
||||
const shuffled = [entry('c'), entry('a'), entry('b')];
|
||||
expect(journalNeighbours(shuffled, 'a')).toEqual({
|
||||
prev: { href: '/journal/c', title: 'C' },
|
||||
next: { href: '/journal/b', title: 'B' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to whatever title is passed in, unchanged', () => {
|
||||
const list = [entry('untitled-entry', 'untitled-entry'), entry('b')];
|
||||
expect(journalNeighbours(list, 'b').prev).toEqual({
|
||||
href: '/journal/untitled-entry',
|
||||
title: 'untitled-entry',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
lib/__tests__/mapService.test.ts
Normal file
85
lib/__tests__/mapService.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* `!exif?.latitude` treats a coordinate of exactly 0 as absent, so a photo on
|
||||
* the equator or the prime meridian never reached the map (#635).
|
||||
*/
|
||||
const getAlbums = vi.fn();
|
||||
const getAlbum = vi.fn();
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: {
|
||||
getAlbums: (...args: unknown[]) => getAlbums(...args),
|
||||
getAlbum: (...args: unknown[]) => getAlbum(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({ subpages: [], cacheTtl: 60_000 }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
cache: { get: () => undefined, set: () => {} },
|
||||
}));
|
||||
|
||||
import { getMapData } from '@/lib/mapService';
|
||||
|
||||
const ALBUM = { id: 'album-1', albumName: 'Trip', slug: 'trip' };
|
||||
|
||||
function asset(id: string, exif: Record<string, unknown>) {
|
||||
return { id, exifInfo: exif };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getAlbums.mockReset();
|
||||
getAlbum.mockReset();
|
||||
getAlbums.mockResolvedValue([ALBUM]);
|
||||
});
|
||||
|
||||
describe('getMapData coordinate filtering (#635)', () => {
|
||||
it('includes a photo at latitude 0 (the equator)', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: 0, longitude: 12.5, city: 'Somewhere', country: 'Kenya' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].albums[0].photoCount).toBe(1);
|
||||
expect(locations[0].albums[0].latSum).toBe(0);
|
||||
});
|
||||
|
||||
it('includes a photo at longitude 0 (the prime meridian)', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: 51.5, longitude: 0, city: 'London', country: 'UK' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(1);
|
||||
expect(locations[0].albums[0].photoCount).toBe(1);
|
||||
});
|
||||
|
||||
it('still excludes a photo with no coordinates at all', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: null, longitude: null, city: 'Nowhere', country: 'None' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still excludes a photo missing city or country', async () => {
|
||||
getAlbum.mockResolvedValue({
|
||||
id: ALBUM.id,
|
||||
assets: [asset('a', { latitude: 0, longitude: 0, city: '', country: 'Kenya' })],
|
||||
});
|
||||
|
||||
const locations = await getMapData();
|
||||
|
||||
expect(locations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
56
lib/__tests__/navActive.test.ts
Normal file
56
lib/__tests__/navActive.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isActivePath } from '@/components/NavLink';
|
||||
|
||||
/**
|
||||
* The header has styled `.active` since it was written and never applied it.
|
||||
* These pin the matching rules, in particular the two that are easy to get
|
||||
* wrong: Home must not light up everywhere, and one subpage slug must not
|
||||
* match another that merely starts the same way.
|
||||
*/
|
||||
describe('isActivePath', () => {
|
||||
it('marks Home only on the home page', () => {
|
||||
expect(isActivePath('/', '/')).toBe(true);
|
||||
expect(isActivePath('/reisen', '/')).toBe(false);
|
||||
expect(isActivePath('/journal/island', '/')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks a subpage on its own page', () => {
|
||||
expect(isActivePath('/reisen', '/reisen')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a subpage while inside one of its albums', () => {
|
||||
// An album lives at /<subpage>/<album>, and the visitor is still "in" that
|
||||
// section of the site.
|
||||
expect(isActivePath('/reisen/island', '/reisen')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not let one slug match another that starts the same way', () => {
|
||||
expect(isActivePath('/reisender', '/reisen')).toBe(false);
|
||||
expect(isActivePath('/reisen-2026', '/reisen')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks Journal on an entry as well as the index', () => {
|
||||
expect(isActivePath('/journal', '/journal')).toBe(true);
|
||||
expect(isActivePath('/journal/eine-reise', '/journal')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks nothing when the path is unknown', () => {
|
||||
expect(isActivePath(null, '/about')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves unrelated links alone', () => {
|
||||
expect(isActivePath('/about', '/map')).toBe(false);
|
||||
expect(isActivePath('/map', '/about')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks exactly one link for a typical header', () => {
|
||||
const header = ['/', '/reisen', '/journal', '/about', '/map'];
|
||||
const activeOn = (pathname: string) => header.filter((h) => isActivePath(pathname, h));
|
||||
|
||||
expect(activeOn('/')).toEqual(['/']);
|
||||
expect(activeOn('/reisen')).toEqual(['/reisen']);
|
||||
expect(activeOn('/reisen/island')).toEqual(['/reisen']);
|
||||
expect(activeOn('/journal/eine-reise')).toEqual(['/journal']);
|
||||
expect(activeOn('/impressum')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePhotoHash, buildPhotoHash, buildPhotoPermalink } from '@/lib/photoHash';
|
||||
import {
|
||||
parsePhotoHash,
|
||||
buildPhotoHash,
|
||||
parsePhotoQuery,
|
||||
buildPhotoQuery,
|
||||
buildPhotoPermalink,
|
||||
} from '@/lib/photoHash';
|
||||
|
||||
describe('buildPhotoHash', () => {
|
||||
it('presents the index 1-indexed', () => {
|
||||
@@ -33,6 +39,46 @@ describe('parsePhotoHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePhotoQuery', () => {
|
||||
it('reads the photo param', () => {
|
||||
expect(parsePhotoQuery('?photo=v2:abc123')).toBe('v2:abc123');
|
||||
});
|
||||
|
||||
it('returns null when absent', () => {
|
||||
expect(parsePhotoQuery('')).toBeNull();
|
||||
expect(parsePhotoQuery('?preview=true')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an empty value', () => {
|
||||
// URLSearchParams reads `?photo=` as the empty string, not absent — callers
|
||||
// treat an empty token the same as "not addressing a photo" downstream,
|
||||
// but this function reports exactly what was in the URL.
|
||||
expect(parsePhotoQuery('?photo=')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPhotoQuery', () => {
|
||||
it('adds the photo param to an empty search string', () => {
|
||||
expect(buildPhotoQuery('', 'v2:abc')).toBe('?photo=v2%3Aabc');
|
||||
});
|
||||
|
||||
it('preserves other query parameters', () => {
|
||||
expect(buildPhotoQuery('?preview=true', 'v2:abc')).toBe('?preview=true&photo=v2%3Aabc');
|
||||
});
|
||||
|
||||
it('replaces an existing photo param rather than duplicating it', () => {
|
||||
expect(buildPhotoQuery('?photo=old', 'v2:new')).toBe('?photo=v2%3Anew');
|
||||
});
|
||||
|
||||
it('removes the param when assetId is null', () => {
|
||||
expect(buildPhotoQuery('?photo=v2:abc&preview=true', null)).toBe('?preview=true');
|
||||
});
|
||||
|
||||
it('returns an empty string when nothing is left', () => {
|
||||
expect(buildPhotoQuery('?photo=v2:abc', null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPhotoPermalink', () => {
|
||||
const location = {
|
||||
origin: 'https://folio.example',
|
||||
@@ -40,19 +86,30 @@ describe('buildPhotoPermalink', () => {
|
||||
search: '',
|
||||
};
|
||||
|
||||
it('builds an absolute link to the photo', () => {
|
||||
expect(buildPhotoPermalink(location, 2)).toBe('https://folio.example/travel/iceland#photo-3');
|
||||
it('builds an absolute link to the photo, addressed by its stable id', () => {
|
||||
expect(buildPhotoPermalink(location, 'v2:abc123')).toBe(
|
||||
'https://folio.example/travel/iceland?photo=v2%3Aabc123',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an existing query string', () => {
|
||||
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 0)).toBe(
|
||||
'https://folio.example/travel/iceland?preview=true#photo-1',
|
||||
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 'v2:abc123')).toBe(
|
||||
'https://folio.example/travel/iceland?preview=true&photo=v2%3Aabc123',
|
||||
);
|
||||
});
|
||||
|
||||
it('works at the site root', () => {
|
||||
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 0)).toBe(
|
||||
'https://folio.example/#photo-1',
|
||||
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 'v2:abc123')).toBe(
|
||||
'https://folio.example/?photo=v2%3Aabc123',
|
||||
);
|
||||
});
|
||||
|
||||
// The whole point: the same asset keeps the same link no matter where it
|
||||
// now sits in the album (#588). The old #photo-N form could not promise
|
||||
// that at all — it named a position, not a photo.
|
||||
it('does not change when the asset moves to a different position', () => {
|
||||
expect(buildPhotoPermalink(location, 'v2:abc123')).toBe(
|
||||
buildPhotoPermalink(location, 'v2:abc123'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('@/lib/auth', () => ({
|
||||
}));
|
||||
|
||||
import { isSiteUnlocked } from '@/lib/auth';
|
||||
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
|
||||
import { proxy, config } from '@/proxy';
|
||||
|
||||
const mockUnlocked = isSiteUnlocked as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -88,6 +89,53 @@ describe('proxy', () => {
|
||||
expect(source).not.toContain('admin');
|
||||
});
|
||||
|
||||
/**
|
||||
* Reading the pattern is not enough: whether a path reaches proxy() is
|
||||
* decided by the regex Next compiles from it, so that is what gets tested.
|
||||
* A path the matcher skips gets no site gate and no CSP.
|
||||
*/
|
||||
describe('matcher, compiled the way Next compiles it', () => {
|
||||
const compiled = tryToParsePath(config.matcher[0]);
|
||||
const reaches = (pathname: string) => new RegExp(compiled.regexStr!).test(pathname);
|
||||
|
||||
it('compiles', () => {
|
||||
expect(compiled.error).toBeUndefined();
|
||||
expect(compiled.regexStr).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(['/', '/journal', '/japan/kyoto', '/admin', '/admin/pages', '/gate', '/install'])(
|
||||
'runs on %s',
|
||||
(pathname) => expect(reaches(pathname)).toBe(true),
|
||||
);
|
||||
|
||||
it.each([
|
||||
'/api',
|
||||
'/api/image/v2:abc',
|
||||
'/_next/static/chunks/main.js',
|
||||
'/_next/image',
|
||||
'/favicon.ico',
|
||||
'/sitemap.xml',
|
||||
'/robots.txt',
|
||||
])('skips %s', (pathname) => expect(reaches(pathname)).toBe(false));
|
||||
|
||||
// An exclusion is a whole segment or a whole filename, never a prefix: a
|
||||
// page whose slug happens to start with one must still be gated.
|
||||
it.each([
|
||||
'/apia-samoa',
|
||||
'/apiary',
|
||||
'/api-docs',
|
||||
'/_next/staticfoo',
|
||||
'/_next/imagery',
|
||||
'/sitemapXxml',
|
||||
'/robots1txt',
|
||||
'/faviconXico',
|
||||
'/robots.txt.bak',
|
||||
'/sitemap.xml/extra',
|
||||
])('runs on %s, which only looks like an exclusion', (pathname) =>
|
||||
expect(reaches(pathname)).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The gate resolves the site password through getConfig(), which throws on a
|
||||
* config it cannot parse — inside the proxy, before app/layout.tsx can fall
|
||||
|
||||
@@ -252,3 +252,77 @@ describe('checkRateLimit', () => {
|
||||
expect(result.remaining).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The previous limiter reset its whole budget the moment `expiresAt` (set
|
||||
* when the window opened, never advanced) passed. A burst timed around that
|
||||
* boundary — most of one window, then most of the next — passed roughly
|
||||
* double the documented limit. 10 admin password attempts landed in one
|
||||
* second instead of 5 per minute.
|
||||
*/
|
||||
describe('checkRateLimit is a sliding window, not a fixed one', () => {
|
||||
let testIp: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
counter++;
|
||||
testIp = `sliding-ip-${counter}-${Date.now()}`;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('does not grant a fresh budget the instant a window rolls over', () => {
|
||||
const maxRpm = 10;
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
|
||||
// Exactly one window later: a fixed window resets to a full fresh
|
||||
// budget here. The previous window's requests still fully overlap the
|
||||
// trailing 60s at this exact instant, so this must still be refused.
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
});
|
||||
|
||||
it('never lets a boundary-timed burst clear roughly double the limit', () => {
|
||||
const maxRpm = 10;
|
||||
// Spend the whole budget right at the end of the first window.
|
||||
vi.advanceTimersByTime(59_000);
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
|
||||
// Cross into the next window and try to spend the whole budget again,
|
||||
// one request per second — a fixed window allows every one of these.
|
||||
let allowedInSecondWindow = 0;
|
||||
for (let i = 0; i < maxRpm; i++) {
|
||||
vi.advanceTimersByTime(1000);
|
||||
if (checkRateLimit(testIp, maxRpm).success) allowedInSecondWindow++;
|
||||
}
|
||||
|
||||
// A tolerant bound, not an exact one: the point is "nowhere near double",
|
||||
// not pinning the estimator's exact rounding.
|
||||
expect(allowedInSecondWindow).toBeLessThan(maxRpm);
|
||||
});
|
||||
|
||||
it('fully forgets a client after two windows of inactivity', () => {
|
||||
const maxRpm = 3;
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(2 * 60_000 + 1);
|
||||
const result = checkRateLimit(testIp, maxRpm);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(maxRpm - 1);
|
||||
});
|
||||
|
||||
it('lets the budget recover gradually as the old window ages out', () => {
|
||||
const maxRpm = 10;
|
||||
for (let i = 0; i < maxRpm; i++) checkRateLimit(testIp, maxRpm);
|
||||
expect(checkRateLimit(testIp, maxRpm).success).toBe(false);
|
||||
|
||||
// Halfway through the next window, roughly half of the previous burst has
|
||||
// aged out of the trailing 60s — some budget should have come back.
|
||||
vi.advanceTimersByTime(90_000);
|
||||
const result = checkRateLimit(testIp, maxRpm);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
124
lib/__tests__/settings-schema.test.ts
Normal file
124
lib/__tests__/settings-schema.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { validateSettings } from '../config/settingsSchema';
|
||||
|
||||
/**
|
||||
* Half of these tests are about what must *not* be rejected.
|
||||
*
|
||||
* The failure mode of a validator on a config file is not "it let something
|
||||
* through" — it is "it locked an operator out of their own settings, through
|
||||
* the panel that writes the file". There is no schemaVersion and no migration
|
||||
* code, so older and newer configurations are both in the wild.
|
||||
*/
|
||||
|
||||
const ok = (input: unknown) => validateSettings(input).ok;
|
||||
const errors = (input: unknown) => {
|
||||
const result = validateSettings(input);
|
||||
return result.ok ? [] : result.errors;
|
||||
};
|
||||
|
||||
describe('validateSettings — accepts what works today', () => {
|
||||
it('accepts the example settings file this repo ships with', () => {
|
||||
// content/settings.yaml is gitignored — the example is the one every
|
||||
// installation starts from, and the one CI actually has.
|
||||
const raw = fs.readFileSync(path.join(process.cwd(), 'content/settings.yaml.example'), 'utf8');
|
||||
|
||||
expect(validateSettings(yaml.load(raw))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts an empty settings file', () => {
|
||||
// Everything has a default; a site with no settings.yaml is a valid site.
|
||||
expect(ok({})).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts both spellings of theme', () => {
|
||||
expect(ok({ theme: 'minimal' })).toBe(true);
|
||||
expect(ok({ theme: { preset: 'minimal', accent: '#c0ffee', radius: 4 } })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a watermark opacity written as a percentage', () => {
|
||||
// The #508 workaround: values above 1 are read as percentages. A validator
|
||||
// that insisted on 0–1 would reject configurations that render correctly.
|
||||
expect(ok({ watermark: { opacity: 90 } })).toBe(true);
|
||||
expect(ok({ watermark: { opacity: 0.5 } })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a colour mode it does not recognise', () => {
|
||||
// resolveColorMode falls back to 'dark'. Rejecting here would be stricter
|
||||
// than the behaviour it guards.
|
||||
expect(ok({ mode: 'sepia' })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts keys this version has never heard of', () => {
|
||||
expect(ok({ title: 'Folio', somethingFromANewerVersion: { nested: true } })).toBe(true);
|
||||
expect(ok({ theme: { preset: 'noir', futureKnob: 3 } })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the legacy exifOnHover switch alongside the group form', () => {
|
||||
expect(ok({ exifOnHover: false, exif: { camera: true, caption: false } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSettings — rejects what breaks the site', () => {
|
||||
it('rejects a payload that is not an object', () => {
|
||||
expect(ok('title: Folio')).toBe(false);
|
||||
expect(ok([{ title: 'Folio' }])).toBe(false);
|
||||
expect(ok(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a scalar where a section belongs', () => {
|
||||
// `grid: 3` reads as "three columns" to a human and as a crash to
|
||||
// buildCoverGridVars.
|
||||
const found = errors({ grid: 3 });
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].field).toBe('grid');
|
||||
});
|
||||
|
||||
it('rejects a section where a scalar belongs', () => {
|
||||
const found = errors({ title: { de: 'Folio', en: 'Folio' } });
|
||||
|
||||
expect(found[0].field).toBe('title');
|
||||
});
|
||||
|
||||
it('names the exact field, however deep', () => {
|
||||
const found = errors({ theme: { fonts: { heading: 42 } } });
|
||||
|
||||
expect(found.map((e) => e.field)).toContain('theme.fonts.heading');
|
||||
});
|
||||
|
||||
it('rejects a theme that is neither a preset name nor a section', () => {
|
||||
// The separate theme check must not turn into a hole: anything that is not
|
||||
// a string still has to look like the object form.
|
||||
expect(ok({ theme: 3 })).toBe(false);
|
||||
expect(ok({ theme: ['minimal'] })).toBe(false);
|
||||
expect(errors({ theme: 3 })[0].field).toBe('theme');
|
||||
});
|
||||
|
||||
it('names an index inside navLinks', () => {
|
||||
const found = errors({ navLinks: [{ label: 'Shop', url: 'https://example.com' }, 'nope'] });
|
||||
|
||||
expect(found[0].field).toBe('navLinks.1');
|
||||
});
|
||||
|
||||
it('rejects a radius written with its unit', () => {
|
||||
// #633: `radius: 8px` reaches CSS as `8pxpx`. The value itself belongs to
|
||||
// the resolver, but a string where a number belongs is a type error, and
|
||||
// that half is caught here for anything saved from the panel.
|
||||
const found = errors({ theme: { radius: '8px' } });
|
||||
|
||||
expect(found.map((e) => e.field)).toContain('theme.radius');
|
||||
});
|
||||
|
||||
it('rejects a list where a record belongs', () => {
|
||||
expect(ok({ legal: ['Ralf', 'Berlin'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('reports every offending field at once, not just the first', () => {
|
||||
const found = errors({ title: 1, subtitle: 2, map: 'yes' });
|
||||
|
||||
expect(found.map((e) => e.field).sort()).toEqual(['map', 'subtitle', 'title']);
|
||||
});
|
||||
});
|
||||
62
lib/__tests__/theme-scalars.test.ts
Normal file
62
lib/__tests__/theme-scalars.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveTheme, THEME_PRESETS, DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
|
||||
/**
|
||||
* `radius` reaches CSS as `${radius}px` and `${radius * 1.5}px` (app/layout.tsx),
|
||||
* so a value that is not actually a number does not fail loudly — it becomes
|
||||
* `8pxpx` and `NaNpx` (#633). Hand-edited YAML can hand resolveTheme anything;
|
||||
* only the panel-save boundary (settingsSchema.ts) already caught the type
|
||||
* half of this, and only for a save from the panel.
|
||||
*/
|
||||
describe('resolveTheme scalar coercion (#633)', () => {
|
||||
const base = THEME_PRESETS[DEFAULT_PRESET];
|
||||
|
||||
it('falls back to the preset radius when the value is a string', () => {
|
||||
// as unknown as number: simulating hand-edited YAML, which yaml.load()
|
||||
// hands back with no runtime guarantee it matches SettingsYaml's types.
|
||||
const theme = resolveTheme({ radius: '8px' as unknown as number });
|
||||
expect(theme.radius).toBe(base.radius);
|
||||
expect(Number.isFinite(theme.radius)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the preset radius when the value is NaN', () => {
|
||||
expect(resolveTheme({ radius: NaN }).radius).toBe(base.radius);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range radius rather than passing it through', () => {
|
||||
expect(resolveTheme({ radius: 999 }).radius).toBeLessThanOrEqual(64);
|
||||
expect(resolveTheme({ radius: -5 }).radius).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a legitimate radius override', () => {
|
||||
expect(resolveTheme({ radius: 24 }).radius).toBe(24);
|
||||
});
|
||||
|
||||
it('falls back to the preset accent when the value is not a string', () => {
|
||||
const theme = resolveTheme({ accent: 42 as unknown as string });
|
||||
expect(theme.accent).toBe(base.accent);
|
||||
});
|
||||
|
||||
it('falls back to the preset accent for an empty string', () => {
|
||||
expect(resolveTheme({ accent: '' }).accent).toBe(base.accent);
|
||||
});
|
||||
|
||||
it('keeps a legitimate accent override', () => {
|
||||
expect(resolveTheme({ accent: '#123456' }).accent).toBe('#123456');
|
||||
});
|
||||
|
||||
it('falls back to the preset grain/headerDot when not a boolean', () => {
|
||||
const theme = resolveTheme({
|
||||
grain: 'yes' as unknown as boolean,
|
||||
headerDot: 1 as unknown as boolean,
|
||||
});
|
||||
expect(theme.grain).toBe(base.grain);
|
||||
expect(theme.headerDot).toBe(base.headerDot);
|
||||
});
|
||||
|
||||
it('keeps legitimate grain/headerDot overrides', () => {
|
||||
const theme = resolveTheme({ grain: !base.grain, headerDot: !base.headerDot });
|
||||
expect(theme.grain).toBe(!base.grain);
|
||||
expect(theme.headerDot).toBe(!base.headerDot);
|
||||
});
|
||||
});
|
||||
@@ -80,3 +80,36 @@ describe('writeYamlFile temp file handling', () => {
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "No file yet" and "the backup could not be written" used to share one
|
||||
* catch, so a `.backups/` this save couldn't write to looked exactly like a
|
||||
* brand-new file and the save went ahead with no snapshot (#630).
|
||||
*/
|
||||
describe('writeYamlFile backup failure', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('still saves a brand-new file with no backup', async () => {
|
||||
await expect(writeGalleryYaml(gallery)).resolves.toBeUndefined();
|
||||
expect(fs.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the save when the file exists but the backup copy fails', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(fs.copyFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts when checking whether the file exists fails for a reason other than ENOENT', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValueOnce(
|
||||
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
|
||||
);
|
||||
|
||||
await expect(writeGalleryYaml(gallery)).rejects.toThrow('EACCES');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +147,24 @@ export function checkProxyHops(
|
||||
};
|
||||
}
|
||||
|
||||
// configuredHops is between 0 and provenHops (both handled above), so the
|
||||
// position read is still inside the chain rather than at its start: every
|
||||
// request is read from a proxy's own address, not the client behind it.
|
||||
// Cloudflare (1 hop) in front of nginx (1 more, 2 total) with the value set
|
||||
// to 1 reads the Cloudflare edge address, and every visitor behind that
|
||||
// edge shares one rate-limit bucket — this used to report "matches the
|
||||
// observed chain".
|
||||
if (configuredHops < provenHops) {
|
||||
return {
|
||||
id: 'proxy-hops',
|
||||
level: 'warn',
|
||||
title: `TRUSTED_PROXY_HOPS is ${configuredHops}, but a chain of ${provenHops} proxy hops was seen`,
|
||||
detail:
|
||||
'The client IP is read from a position still inside the proxy chain, not the real client — ' +
|
||||
`every visitor behind that hop can share one rate-limit bucket. ${provenHops} is what this request suggests.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'proxy-hops',
|
||||
level: 'ok',
|
||||
@@ -244,6 +262,77 @@ export function checkAlbumsShared(configured: string[], known: AlbumRef[]): Doct
|
||||
};
|
||||
}
|
||||
|
||||
/** One set of albums that will all be reachable under the same URL prefix. */
|
||||
export interface AlbumSlugGroup {
|
||||
/** e.g. "gallery.yaml albums" or `subpage "Trips"` */
|
||||
context: string;
|
||||
albumIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Album slug collisions using each album's *resolved* name — the title
|
||||
* override if one is set, otherwise whatever Immich calls it. deriveGallery
|
||||
* can only see the override half of that (#632); this is the other half,
|
||||
* checked against the live album list the doctor already has.
|
||||
*
|
||||
* `slugOf` is a parameter rather than an import so this file keeps the zero
|
||||
* dependencies it has had since #491 — the whole reason `scripts/doctor.mts`
|
||||
* can run this module directly through Node's native TypeScript stripping,
|
||||
* with no bundler and no `lib/config` (which pulls in `fs`).
|
||||
*/
|
||||
export function checkAlbumSlugCollisions(
|
||||
groups: AlbumSlugGroup[],
|
||||
overrides: Record<string, string>,
|
||||
known: AlbumRef[],
|
||||
slugOf: (name: string) => string,
|
||||
): DoctorFinding {
|
||||
const byId = new Map(known.map((a) => [a.id, a]));
|
||||
const collisions: string[] = [];
|
||||
const collidingIds: string[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const named = group.albumIds
|
||||
.map((id) => ({ id, name: overrides[id] ?? byId.get(id)?.albumName }))
|
||||
.filter((a): a is { id: string; name: string } => !!a.name);
|
||||
|
||||
const bySlug = new Map<string, typeof named>();
|
||||
for (const album of named) {
|
||||
const slug = slugOf(album.name);
|
||||
if (!slug) continue; // Falls back to the id elsewhere, which is unique.
|
||||
const list = bySlug.get(slug) ?? [];
|
||||
list.push(album);
|
||||
bySlug.set(slug, list);
|
||||
}
|
||||
|
||||
for (const [slug, collided] of bySlug) {
|
||||
if (collided.length < 2) continue;
|
||||
collisions.push(
|
||||
`${group.context}: "${collided.map((a) => a.name).join('" and "')}" all resolve to /${slug}`,
|
||||
);
|
||||
collidingIds.push(...collided.map((a) => a.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (collisions.length) {
|
||||
return {
|
||||
id: 'album-slugs',
|
||||
level: 'error',
|
||||
title: `${collisions.length} album slug ${collisions.length === 1 ? 'collision' : 'collisions'} found`,
|
||||
detail:
|
||||
'Every album after the first in a collision is unreachable at its own URL, and its ' +
|
||||
`settings resolve from the one before it. ${collisions.join('; ')}`,
|
||||
albumIds: collidingIds,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'album-slugs',
|
||||
level: 'ok',
|
||||
title: 'No album slug collisions',
|
||||
detail: 'Every album reachable under the same URL prefix has a distinct slug.',
|
||||
};
|
||||
}
|
||||
|
||||
export interface PasswordRef {
|
||||
/** Where it lives, for the report: "Album japan-2024", "Site password". */
|
||||
label: string;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import fs from 'fs/promises';
|
||||
import nodeFs from 'fs';
|
||||
import path from 'path';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
calculateReadingTime,
|
||||
@@ -19,7 +20,6 @@ const JOURNAL_DIR = path.join(process.cwd(), 'content', 'journal');
|
||||
const LEGACY_ESSAYS_DIR = path.join(process.cwd(), 'content', 'essays');
|
||||
const BACKUP_DIR = path.join(JOURNAL_DIR, '.backups');
|
||||
const MAX_BACKUPS = 10;
|
||||
let tmpCounter = 0;
|
||||
|
||||
/**
|
||||
* Names this service writes into .backups/, and the only ones it restores from.
|
||||
@@ -127,14 +127,7 @@ export async function restoreJournalBackup(backupFilename: string): Promise<stri
|
||||
}
|
||||
|
||||
await fs.mkdir(JOURNAL_DIR, { recursive: true });
|
||||
const tmpPath = `${target}.${process.pid}.${++tmpCounter}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content, 'utf8');
|
||||
await fs.rename(tmpPath, target);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await atomicWrite(target, content);
|
||||
|
||||
console.log(`[Journal] 🔄 Restored ${filename} from ${backupFilename}`);
|
||||
return slug;
|
||||
@@ -280,23 +273,42 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
|
||||
throw new Error(`Invalid journal slug: "${slug}"`);
|
||||
}
|
||||
|
||||
// Create rolling backup if file already exists
|
||||
// "No file yet" and "the backup could not be written" used to share one
|
||||
// catch, so a `.backups/` a save couldn't write to looked exactly like a
|
||||
// brand-new entry: the save went ahead with no snapshot taken (#630). Only
|
||||
// ENOENT means there is nothing to back up; anything else aborts the save
|
||||
// before it overwrites the live file.
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
await snapshotEntry(filePath, filename);
|
||||
await pruneEntryBackups(filename);
|
||||
} catch {
|
||||
// New file, no backup needed
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
// Atomic write via unique temp file
|
||||
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
|
||||
if (fileExists) {
|
||||
await snapshotEntry(filePath, filename);
|
||||
await pruneEntryBackups(filename);
|
||||
}
|
||||
|
||||
await atomicWrite(filePath, rawMarkdown);
|
||||
|
||||
// An entry that started in content/essays/ now has a copy in both
|
||||
// directories; resolveJournalFilePath would keep favouring this new one,
|
||||
// but deleteJournalEntry used to only ever see one path at a time and could
|
||||
// leave the legacy copy behind to resurface on the next listing (#631).
|
||||
// Retiring it here, once the new content is safely on disk, means delete
|
||||
// never has to reconcile two files for one slug.
|
||||
const legacyPath = containedPath(LEGACY_ESSAYS_DIR, filename);
|
||||
if (legacyPath) {
|
||||
try {
|
||||
await fs.writeFile(tmpPath, rawMarkdown, 'utf8');
|
||||
await fs.rename(tmpPath, filePath);
|
||||
await fs.access(legacyPath);
|
||||
await snapshotEntry(legacyPath, filename);
|
||||
await fs.unlink(legacyPath);
|
||||
console.log(`[Journal] 🧹 Retired legacy copy of ${filename} in content/essays/`);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Journal] ✅ Saved ${filename}`);
|
||||
@@ -316,22 +328,33 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
|
||||
throw new Error(`Invalid journal slug: "${slug}"`);
|
||||
}
|
||||
|
||||
const filePath = resolveJournalFilePath(slug);
|
||||
if (!filePath) return false;
|
||||
const filename = `${slug}.md`;
|
||||
const primaryPath = containedPath(JOURNAL_DIR, filename);
|
||||
const legacyPath = containedPath(LEGACY_ESSAYS_DIR, filename);
|
||||
if (!primaryPath || !legacyPath) return false;
|
||||
|
||||
// Check both directories, not just whichever resolveJournalFilePath would
|
||||
// pick: an entry that was saved once while still in content/essays/ used to
|
||||
// leave a copy there, which listJournalEntries would bring back as soon as
|
||||
// the content/journal/ copy was deleted (#631). writeJournalEntry now
|
||||
// retires the legacy copy on every save, but a legacy entry that was never
|
||||
// edited still lives there alone, so both paths must be checked here too.
|
||||
let deletedAny = false;
|
||||
for (const filePath of [primaryPath, legacyPath]) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
await snapshotEntry(filePath, `${slug}.md`, 'deleted');
|
||||
|
||||
await snapshotEntry(filePath, filename, 'deleted');
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
console.log(`[Journal] 🗑️ Deleted ${slug}.md`);
|
||||
return true;
|
||||
deletedAny = true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw err;
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedAny) console.log(`[Journal] 🗑️ Deleted ${filename}`);
|
||||
return deletedAny;
|
||||
}
|
||||
|
||||
32
lib/admin/withAdmin.ts
Normal file
32
lib/admin/withAdmin.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from './auth';
|
||||
|
||||
/**
|
||||
* Wraps an admin route handler in the enabled + authenticated check.
|
||||
*
|
||||
* Every handler under /api/admin is defined inside this wrapper, so a route
|
||||
* that forgets its guard is not a diff that merely looks fine: there is no
|
||||
* plain `export async function GET` left to copy from. The one exception is
|
||||
* /api/admin/auth, the login endpoint, which must stay reachable without a
|
||||
* session.
|
||||
*
|
||||
* The two refusals are kept distinct, as the hand-written copies had them:
|
||||
* 403 means the admin panel is switched off for this deployment, 401 means it
|
||||
* is on and this caller is not signed in.
|
||||
*
|
||||
* The handler's own parameters are passed through untouched, so routes taking
|
||||
* `(request)`, `(request, context)` or nothing at all all wrap the same way.
|
||||
*/
|
||||
export function withAdmin<Args extends unknown[]>(
|
||||
handler: (...args: Args) => Response | Promise<Response>,
|
||||
): (...args: Args) => Promise<Response> {
|
||||
return async (...args: Args): Promise<Response> => {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return handler(...args);
|
||||
};
|
||||
}
|
||||
@@ -6,14 +6,12 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { atomicWrite } from '../atomicWrite';
|
||||
import type { GalleryYaml, SettingsYaml } from '../config/schema';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const MAX_BACKUPS = 10; // Keep last 10 backups per file
|
||||
|
||||
/** Disambiguates temp files within one process; the pid covers across them. */
|
||||
let tmpCounter = 0;
|
||||
|
||||
/** Read gallery.yaml and return parsed content. */
|
||||
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
|
||||
try {
|
||||
@@ -43,9 +41,21 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
// Ensure content directory exists
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
|
||||
// Create backup if file exists
|
||||
// "No file yet" and "the backup could not be written" used to share one
|
||||
// catch, so a `.backups/` a save couldn't write to (owned by root after a
|
||||
// first start as root, say) looked exactly like a brand-new file: the save
|
||||
// went ahead with no snapshot taken (#630). Only ENOENT means there is
|
||||
// nothing to back up; anything else aborts the save before it overwrites
|
||||
// the live file.
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
if (fileExists) {
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
@@ -55,8 +65,6 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
|
||||
// Prune old backups
|
||||
await pruneBackups(backupDir, filename);
|
||||
} catch {
|
||||
// File doesn't exist yet, no backup needed
|
||||
}
|
||||
|
||||
// Generate YAML content with header comment
|
||||
@@ -74,23 +82,7 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
sortKeys: false,
|
||||
});
|
||||
|
||||
// Atomic write: write to a *unique* temp file, then rename.
|
||||
//
|
||||
// The rename is what makes this atomic; the temp filename is what did not.
|
||||
// With a constant `${filePath}.tmp`, two saves of the same file in flight at
|
||||
// once shared it — the second writeFile could interleave with the first's
|
||||
// rename, leaving one save's bytes published under the other's, or a
|
||||
// truncated mix. A double-clicked Save in the page builder is enough.
|
||||
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content, 'utf8');
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
// Do not leave litter in content/ behind a failed save. Cleanup failure is
|
||||
// not worth masking the real error with.
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await atomicWrite(filePath, content);
|
||||
|
||||
console.log(`[Admin] ✅ Saved ${filename}`);
|
||||
}
|
||||
@@ -148,31 +140,63 @@ export async function restoreBackup(backupFilename: string): Promise<void> {
|
||||
const originalFilename = match[1];
|
||||
const targetPath = path.join(CONTENT_DIR, originalFilename);
|
||||
|
||||
// Create a backup of current state first
|
||||
// Read before touching anything, so a missing or unreadable backup changes
|
||||
// nothing on disk.
|
||||
const content = await fs.readFile(backupPath);
|
||||
|
||||
// Create a backup of current state first. A failed safety copy aborts the
|
||||
// restore rather than being swallowed: `fs.copyFile` below would otherwise
|
||||
// truncate `targetPath` before writing it, so on a full disk or a container
|
||||
// killed mid-copy the live file could be lost with no snapshot to fall back
|
||||
// on (#630). Only ENOENT — there is no current file to snapshot, which is
|
||||
// the normal case for a deleted entry — is not an abort.
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const preRestoreBackup = `${originalFilename}.${timestamp}.pre-restore.bak`;
|
||||
try {
|
||||
await fs.copyFile(targetPath, path.join(backupDir, preRestoreBackup));
|
||||
} catch {
|
||||
// Original might not exist
|
||||
// Bound them here rather than waiting for the next save: restoring twice in
|
||||
// a row is exactly when these pile up, and a save may be a long way off.
|
||||
await pruneBackups(backupDir, originalFilename);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
await fs.copyFile(backupPath, targetPath);
|
||||
// atomicWrite rather than fs.copyFile: a copy truncates the destination
|
||||
// before writing, so a failure partway through (full disk, killed
|
||||
// container) leaves targetPath empty. Temp-file-then-rename means the old
|
||||
// content stays in place until the new content is fully on disk.
|
||||
await atomicWrite(targetPath, content);
|
||||
console.log(`[Admin] 🔄 Restored ${originalFilename} from ${backupFilename}`);
|
||||
}
|
||||
|
||||
/** Remove old backups, keeping only MAX_BACKUPS most recent. */
|
||||
/**
|
||||
* Remove old backups, keeping the newest MAX_BACKUPS of each kind.
|
||||
*
|
||||
* The two kinds are counted separately on purpose: ten ordinary saves must not
|
||||
* push out the snapshot taken just before a restore, which is the only way back
|
||||
* from one. That is why pre-restore files were exempt from pruning.
|
||||
*
|
||||
* Exempting them entirely was the bug, though. listBackups() returns them and
|
||||
* the modal lists them, so every restore added a row that was never removed
|
||||
* until the useful backups were off the end of the list. A cap of their own
|
||||
* keeps the undo without letting it crowd out everything else.
|
||||
*/
|
||||
async function pruneBackups(backupDir: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.readdir(backupDir);
|
||||
const relevant = files
|
||||
.filter((f) => f.startsWith(filename) && !f.includes('pre-restore'))
|
||||
.sort();
|
||||
|
||||
if (relevant.length > MAX_BACKUPS) {
|
||||
const toDelete = relevant.slice(0, relevant.length - MAX_BACKUPS);
|
||||
for (const f of toDelete) {
|
||||
await fs.unlink(path.join(backupDir, f));
|
||||
const saves: string[] = [];
|
||||
const preRestores: string[] = [];
|
||||
for (const f of files.filter((f) => f.startsWith(filename))) {
|
||||
(f.includes('pre-restore') ? preRestores : saves).push(f);
|
||||
}
|
||||
|
||||
// The timestamp is fixed-width and lexically ordered, so sorting by name
|
||||
// sorts by age.
|
||||
for (const group of [saves, preRestores]) {
|
||||
group.sort();
|
||||
for (const old of group.slice(0, Math.max(0, group.length - MAX_BACKUPS))) {
|
||||
await fs.unlink(path.join(backupDir, old));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
56
lib/atomicWrite.ts
Normal file
56
lib/atomicWrite.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Monotonic within the process, so two writes to the same path started in the
|
||||
* same millisecond cannot land on the same temp file. Several of the copies
|
||||
* this replaces used `Date.now()`, which can.
|
||||
*/
|
||||
let tmpCounter = 0;
|
||||
|
||||
/**
|
||||
* Write a file atomically: unique temp file, then rename.
|
||||
*
|
||||
* The rename is what makes this atomic; the temp filename is what did not.
|
||||
* With a constant `${filePath}.tmp`, two saves of the same file in flight at
|
||||
* once shared it — the second writeFile could interleave with the first's
|
||||
* rename, leaving one save's bytes published under the other's, or a truncated
|
||||
* mix. A double-clicked Save in the page builder is enough.
|
||||
*
|
||||
* Readers matter as much as writers: `GET /api/favicon` reads its file on every
|
||||
* request, so a plain writeFile would let a concurrent read see a half-written
|
||||
* icon.
|
||||
*
|
||||
* No encoding is passed. `fs.writeFile` defaults to utf8 for a string and
|
||||
* writes a Buffer verbatim, which is what every caller wants — the copies this
|
||||
* replaces variously said `'utf8'`, `'utf-8'` or nothing at all.
|
||||
*
|
||||
* Creating the directory is the caller's business; this only writes the file.
|
||||
*
|
||||
* `mode`, when given, is applied to the temp file before the rename — not
|
||||
* `chmod`ed on the final path afterwards. A file holding a secret (an API
|
||||
* key, an auth secret) that is written world-readable and only tightened a
|
||||
* moment later is briefly the wrong permission on disk, and briefly is all a
|
||||
* reader running at the wrong instant needs. `rename` keeps whatever mode the
|
||||
* source had, so creating the temp file with the right one from the start
|
||||
* means the secret is never world-readable at all.
|
||||
*/
|
||||
export async function atomicWrite(
|
||||
filePath: string,
|
||||
content: string | Buffer,
|
||||
options?: { mode?: number },
|
||||
): Promise<void> {
|
||||
const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(
|
||||
tmpPath,
|
||||
content,
|
||||
options?.mode !== undefined ? { mode: options.mode } : undefined,
|
||||
);
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
// Do not leave litter behind a failed save. Cleanup failure is not worth
|
||||
// masking the real error with.
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
16
lib/auth.ts
16
lib/auth.ts
@@ -126,6 +126,22 @@ export function isAlbumReachable(
|
||||
return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The albums this visitor may see the photographs of: every album without a
|
||||
* password of its own, plus the ones already unlocked.
|
||||
*
|
||||
* For views that gather photographs from several albums onto one page — the
|
||||
* essay layout — where there is no single album to put a password form in
|
||||
* front of. A locked album is left out rather than gating the whole page: the
|
||||
* visitor unlocks it at its own URL and finds it here afterwards.
|
||||
*/
|
||||
export function withoutLockedAlbums<T extends { id: string }>(
|
||||
albums: T[],
|
||||
getCookie: (name: string) => string | undefined,
|
||||
): T[] {
|
||||
return albums.filter((a) => isAuthenticated(a.id, getCookie, 'album'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the SubpageConfig for a given slug.
|
||||
*/
|
||||
|
||||
@@ -10,8 +10,13 @@ import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumS
|
||||
import { LOCATION_PRECISIONS, isLocationPrecision, type LocationPrecision } from '../mapPrecision';
|
||||
import {
|
||||
slugify,
|
||||
findAlbumSlugCollisions,
|
||||
resolveExifDisplay,
|
||||
resolveColorMode,
|
||||
clamp,
|
||||
PHOTO_GRID_COLUMNS_MIN,
|
||||
PHOTO_GRID_COLUMNS_MAX,
|
||||
PHOTO_GRID_GAP_MAX,
|
||||
AppConfig,
|
||||
AlbumEntryObject,
|
||||
SubpageConfig,
|
||||
@@ -65,11 +70,22 @@ interface RawGridOverrides {
|
||||
layout?: string;
|
||||
}
|
||||
|
||||
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
|
||||
/**
|
||||
* Converts raw YAML grid overrides into a typed partial GridConfig.
|
||||
*
|
||||
* `columns` and `gap` are clamped here, not left for the renderer: a subpage
|
||||
* or album override reaches `--grid-columns` unclamped (app/[...path]/page.tsx),
|
||||
* so `columns: -1` produced an invalid `repeat(-1, 1fr)` and collapsed the
|
||||
* whole grid to one column with no error anywhere (#633). The cover grid had
|
||||
* this already (buildCoverGridVars); this is the same treatment for the photo
|
||||
* grid it was missing from.
|
||||
*/
|
||||
function parseGridOverrides(raw: RawGridOverrides): Partial<GridConfig> {
|
||||
return {
|
||||
...(raw.columns != null ? { columns: raw.columns } : {}),
|
||||
...(raw.gap != null ? { gap: raw.gap } : {}),
|
||||
...(raw.columns != null
|
||||
? { columns: clamp(raw.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX) }
|
||||
: {}),
|
||||
...(raw.gap != null ? { gap: clamp(raw.gap, 0, PHOTO_GRID_GAP_MAX) } : {}),
|
||||
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
|
||||
...(raw.layout != null
|
||||
? {
|
||||
@@ -418,10 +434,53 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
|
||||
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
|
||||
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
|
||||
const standaloneAlbums = standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id));
|
||||
|
||||
// Every consumer that resolves a slug takes the first match — getAlbumBySlug,
|
||||
// the sitemap, findPassword — so two subpages that slugify alike leave the
|
||||
// second unreachable and its password / albums resolved from the first
|
||||
// (#632). slugify() folds diacritics and punctuation, so distinct names
|
||||
// collide easily: "Portfolio 2024" and "Portfolio-2024" both become
|
||||
// "portfolio-2024".
|
||||
const subpagesBySlug = new Map<string, string[]>();
|
||||
for (const sp of subpages) {
|
||||
const names = subpagesBySlug.get(sp.slug) ?? [];
|
||||
names.push(sp.name);
|
||||
subpagesBySlug.set(sp.slug, names);
|
||||
}
|
||||
for (const [slug, names] of subpagesBySlug) {
|
||||
if (names.length > 1) {
|
||||
throw new Error(
|
||||
`Subpages ${names.map((n) => `"${n}"`).join(' and ')} all produce the slug "${slug}". ` +
|
||||
`Rename one so their URLs do not collide.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Same rule applies to albums that share a route (standalone albums among
|
||||
// themselves, or one subpage's albums including its sections). Only a title
|
||||
// override is known here without asking Immich — an album that keeps its
|
||||
// Immich name can still collide, which is what the doctor's equivalent
|
||||
// check, run against the live album list, is for.
|
||||
const checkAlbumOverrideCollisions = (context: string, ids: string[]) => {
|
||||
const named = ids
|
||||
.map((id) => ({ id, name: albumOverrides[id] }))
|
||||
.filter((a): a is { id: string; name: string } => !!a.name);
|
||||
for (const { slug, albums } of findAlbumSlugCollisions(named)) {
|
||||
throw new Error(
|
||||
`${context}: albums titled ${albums.map((a) => `"${a.name}"`).join(' and ')} all produce ` +
|
||||
`the slug "${slug}". Give one a different title override.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
checkAlbumOverrideCollisions('gallery.yaml albums', standaloneAlbums);
|
||||
for (const sp of subpages) {
|
||||
checkAlbumOverrideCollisions(`subpage "${sp.name}"`, sp.albumIds);
|
||||
}
|
||||
|
||||
return {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
standaloneAlbums,
|
||||
subpages,
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
@@ -587,8 +646,11 @@ export function getConfig(): AppConfig {
|
||||
exifOnHover: settings.exifOnHover !== false,
|
||||
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
|
||||
grid: {
|
||||
columns: settings.grid?.columns ?? 3,
|
||||
gap: settings.grid?.gap ?? 12,
|
||||
columns:
|
||||
settings.grid?.columns != null
|
||||
? clamp(settings.grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX)
|
||||
: 3,
|
||||
gap: settings.grid?.gap != null ? clamp(settings.grid.gap, 0, PHOTO_GRID_GAP_MAX) : 12,
|
||||
aspectRatio: settings.grid?.aspectRatio ?? '1',
|
||||
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
|
||||
? settings.grid!.layout
|
||||
|
||||
@@ -159,10 +159,19 @@ export function buildCoverGridVars(
|
||||
return vars;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
/**
|
||||
* `unknown` rather than `number`: this also guards the site-wide and
|
||||
* per-subpage/album grid columns and gap (lib/config/index.ts), which a
|
||||
* hand-edited gallery.yaml or settings.yaml can hand a string, `null`, or
|
||||
* anything else. `typeof value !== 'number'` catches those before
|
||||
* `Number.isFinite` gets a chance to — it would otherwise coerce nothing and
|
||||
* just report `false` for a non-number, which happens to be what we want here
|
||||
* too, but checking the type first makes that not an accident.
|
||||
*/
|
||||
export function clamp(value: unknown, min: number, max: number): number {
|
||||
// NaN from a malformed YAML value would survive Math.min/Math.max, and
|
||||
// `repeat(NaN, 1fr)` is an invalid declaration that drops the whole rule.
|
||||
if (!Number.isFinite(value)) return min;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, Math.round(value)));
|
||||
}
|
||||
|
||||
@@ -539,6 +548,36 @@ export function albumSlug(name: string, id: string): string {
|
||||
return slugify(name) || id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups of albums whose slug collides, among a set that will all be
|
||||
* reachable under one URL prefix — the standalone albums, or one subpage's
|
||||
* albums (top-level and sectioned together, since they share a route).
|
||||
*
|
||||
* Every consumer that resolves a slug — getAlbumBySlug, the sitemap, the page
|
||||
* builder — takes the first match, so an unflagged collision makes the second
|
||||
* album unreachable and has its settings (password, sort, grid…) resolved
|
||||
* from the first instead (#632).
|
||||
*
|
||||
* An empty slug (a name with no letters or digits at all) is excluded:
|
||||
* albumSlug() falls back to the album id for that case, which is unique by
|
||||
* construction, so it can never actually collide.
|
||||
*/
|
||||
export function findAlbumSlugCollisions(
|
||||
albums: Array<{ id: string; name: string }>,
|
||||
): Array<{ slug: string; albums: Array<{ id: string; name: string }> }> {
|
||||
const bySlug = new Map<string, Array<{ id: string; name: string }>>();
|
||||
for (const album of albums) {
|
||||
const slug = slugify(album.name);
|
||||
if (!slug) continue;
|
||||
const group = bySlug.get(slug) ?? [];
|
||||
group.push(album);
|
||||
bySlug.set(slug, group);
|
||||
}
|
||||
return Array.from(bySlug.entries())
|
||||
.filter(([, group]) => group.length > 1)
|
||||
.map(([slug, group]) => ({ slug, albums: group }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a slug that came in from a URL before comparing it to a stored one.
|
||||
*
|
||||
|
||||
201
lib/config/settingsSchema.ts
Normal file
201
lib/config/settingsSchema.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Runtime shape check for settings.yaml, used before the admin panel writes it.
|
||||
*
|
||||
* `PUT /api/admin/settings` used to cast the request body and write it. The
|
||||
* gallery route has had a net since a save there "used to report Saved
|
||||
* successfully and then take the public site down"; this is the same net for
|
||||
* settings (#599).
|
||||
*
|
||||
* ── What it checks, and what it deliberately does not ──────────────
|
||||
*
|
||||
* **Types and structure, not values.** A string where an object belongs, an
|
||||
* array where a record belongs, a number where a switch belongs — those are the
|
||||
* shapes that make a resolver throw, and they are what this rejects.
|
||||
*
|
||||
* Value narrowing belongs in the resolvers, because they are the only place
|
||||
* that also sees hand-edited YAML — this check only ever sees what the panel
|
||||
* sends. Several already do it: `resolveColorMode` falls back to `dark` for
|
||||
* anything it does not know, `resolveWatermarkOpacity` accepts both a fraction
|
||||
* and a percentage because a documented workaround produced the latter (#508),
|
||||
* and the album sort and location resolvers throw with a message naming the
|
||||
* offending value. Repeating those rules here would mean two places to keep in
|
||||
* step, and a stricter one would reject configurations that work today.
|
||||
*
|
||||
* `grid.columns` and `theme.radius` got the same treatment in #633: the
|
||||
* site-wide and per-subpage/album grid derivation (`lib/config/index.ts`)
|
||||
* clamps `columns` and `gap` to the bounds the admin inputs already share,
|
||||
* and `resolveTheme` (`lib/config/theme.ts`) coerces `radius`, `accent`,
|
||||
* `grain` and `headerDot` the same way `resolveColorMode` does — falling back
|
||||
* 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.
|
||||
*
|
||||
* **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
|
||||
* rejecting it would lock an operator out of their own settings through the
|
||||
* very panel that writes the file. Hence `looseObject` throughout.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const str = z.string().optional();
|
||||
const bool = z.boolean().optional();
|
||||
const num = z.number().optional();
|
||||
|
||||
const gridLike = z.looseObject({
|
||||
columns: num,
|
||||
gap: num,
|
||||
aspectRatio: str,
|
||||
layout: str,
|
||||
});
|
||||
|
||||
export const settingsSchema = z.looseObject({
|
||||
title: str,
|
||||
subtitle: str,
|
||||
url: str,
|
||||
lang: str,
|
||||
|
||||
seo: z
|
||||
.looseObject({
|
||||
title: str,
|
||||
description: str,
|
||||
titleTemplate: str,
|
||||
noIndex: bool,
|
||||
noFollow: bool,
|
||||
license: str,
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Narrowed by resolveColorMode, which falls back to 'dark'.
|
||||
mode: str,
|
||||
|
||||
exifOnHover: bool,
|
||||
exif: z
|
||||
.looseObject({
|
||||
camera: bool,
|
||||
settings: bool,
|
||||
location: bool,
|
||||
caption: bool,
|
||||
})
|
||||
.optional(),
|
||||
|
||||
map: bool,
|
||||
transitions: bool,
|
||||
scrollToTop: bool,
|
||||
analytics: bool,
|
||||
|
||||
proofing: z.looseObject({ enabled: bool, allowMailto: bool }).optional(),
|
||||
|
||||
// Checked separately in validateSettings — see themeObjectSchema.
|
||||
theme: z.unknown().optional(),
|
||||
|
||||
grid: gridLike.optional(),
|
||||
|
||||
footer: z.looseObject({ name: str, instagram: str, email: str, website: str }).optional(),
|
||||
|
||||
legal: z
|
||||
.looseObject({
|
||||
enabled: bool,
|
||||
name: str,
|
||||
address: str,
|
||||
zipCity: str,
|
||||
country: str,
|
||||
email: str,
|
||||
phone: str,
|
||||
taxId: str,
|
||||
vatId: str,
|
||||
extraInfo: str,
|
||||
})
|
||||
.optional(),
|
||||
|
||||
navLinks: z.array(z.looseObject({ label: str, url: str })).optional(),
|
||||
|
||||
protection: z.looseObject({ disableRightClick: bool, disableImageDrag: bool }).optional(),
|
||||
|
||||
sitePassword: str,
|
||||
|
||||
watermark: z
|
||||
.looseObject({
|
||||
enabled: bool,
|
||||
text: str,
|
||||
// Not bounded here: resolveWatermarkOpacity reads anything above 1 as a
|
||||
// percentage, which is what keeps the #508 workaround working.
|
||||
opacity: num,
|
||||
position: str,
|
||||
})
|
||||
.optional(),
|
||||
|
||||
about: z.looseObject({ enabled: bool }).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The object form of `theme`, checked on its own rather than as a union branch.
|
||||
*
|
||||
* `theme` accepts either a preset name or an object overriding fields on top of
|
||||
* one. Expressed as `z.union([z.string(), …])`, a mistake deep inside the
|
||||
* object reports at `theme` — zod cannot say which branch was meant, so it
|
||||
* blames the field. Dispatching on the type first is what turns that back into
|
||||
* `theme.fonts.heading`, which is the difference between the panel marking the
|
||||
* offending input and shrugging at the whole section.
|
||||
*/
|
||||
const themeObjectSchema = z.looseObject({
|
||||
preset: str,
|
||||
accent: str,
|
||||
fonts: z.looseObject({ heading: str, body: str, caption: str }).optional(),
|
||||
radius: num,
|
||||
photoFrame: str,
|
||||
grain: bool,
|
||||
headerDot: bool,
|
||||
heroStyle: str,
|
||||
});
|
||||
|
||||
export interface SettingsFieldError {
|
||||
/** Dotted path to the offending field, e.g. `theme.radius`. */
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type SettingsValidation = { ok: true } | { ok: false; errors: SettingsFieldError[] };
|
||||
|
||||
/**
|
||||
* Validate a settings payload before it is written.
|
||||
*
|
||||
* Errors carry the field path so the panel can put the message next to the
|
||||
* input that caused it, rather than showing one opaque failure for a file with
|
||||
* forty fields in it.
|
||||
*/
|
||||
export function validateSettings(input: unknown): SettingsValidation {
|
||||
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ field: '', message: 'Settings must be an object.' }],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: SettingsFieldError[] = [];
|
||||
|
||||
const result = settingsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
errors.push(
|
||||
...result.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const theme = (input as { theme?: unknown }).theme;
|
||||
if (theme !== undefined && typeof theme !== 'string') {
|
||||
const themeResult = themeObjectSchema.safeParse(theme);
|
||||
if (!themeResult.success) {
|
||||
errors.push(
|
||||
...themeResult.error.issues.map((issue) => ({
|
||||
field: ['theme', ...issue.path].join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user