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:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -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 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(byToken);
return;
}
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < displayedAssets.length) {
// state from URL hash on mount only
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(idx);
}
}, [displayedAssets.length]);
}, [displayedAssets]);
// ── Sync URL hash when lightbox state changes ─────────────────
// ── Sync the URL's `photo` param when the lightbox state changes ──
useEffect(() => {
if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) {
const hash = buildPhotoHash(lightboxIndex);
if (window.location.hash !== hash) {
window.history.replaceState(null, '', hash);
}
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.length]);
}, [lightboxIndex, displayedAssets]);
// ── Listen for browser back/forward (hash change) ─────────────
// ── Listen for browser back/forward ────────────────────────────
useEffect(() => {
const handleHashChange = () => {
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < displayedAssets.length) {
setLightboxIndex(idx);
} else {
setLightboxIndex(null);
}
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) => {
setLightboxIndex(index);
window.history.pushState(null, '', buildPhotoHash(index));
}, []);
const openLightbox = useCallback(
(index: number) => {
setLightboxIndex(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(() => {

View File

@@ -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,22 +135,23 @@ export function SubpageGridView({
return (
<div className="subpage-container">
{(title || subtitle) && (
<header className="subpage-header">
<div className="subpage-header__main">
{index !== undefined && (
<p className="subpage-header__kicker" aria-hidden="true">
{t.subpage.collectionKicker(pad2(index))}
</p>
)}
{title && <h1 className="subpage-title">{title}</h1>}
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
</div>
<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))}
</p>
)}
{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>
);
}

View File

@@ -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 } }
: {})}
/>
);
}

View File

@@ -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>

View File

@@ -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>;

View 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();
});
});

View 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'));
});
});

View 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' },
]);
});
});

View File

@@ -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) => (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${t.match.test(pathname) ? 'active' : ''}`}
>
{t.label}
</Link>
))}
{TABS.map((t) => {
const active = t.match.test(pathname);
return (
<Link
key={t.href}
href={t.href}
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>
</>
)}

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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) {
const data = await res.json();
const md = data.entry.rawMarkdown;
setRawMarkdown(md);
setParsed(parseJournalMarkdown(md));
setDirty(false);
} else {
alert('Failed to load journal entry');
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
} catch {
alert('Error loading journal entry');
const data = await res.json();
const md = data.entry.rawMarkdown;
setRawMarkdown(md);
setParsed(parseJournalMarkdown(md));
setDirty(false);
} 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 */}

View File

@@ -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) {
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
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;

View File

@@ -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) {
const { settings: data, siteUrl } = await res.json();
setSettings(data || {});
setSiteUrlInfo(siteUrl ?? null);
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) {
const data = await res.json();
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
setAboutLoaded(true);
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>

View File

@@ -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();
});
});

View 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);
});
});

View 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 };
});
}

View File

@@ -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;
}
/**

View 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;
}

View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View 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();
});
});

View File

@@ -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([]);
});
});

View File

@@ -14,18 +14,28 @@ vi.mock('@/lib/admin/auth', () => ({
COOKIE_NAME: 'folio_admin_session',
}));
const baseConfig = {
needsCredentials: true,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
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: () => ({
needsCredentials: true,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [],
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: '', apiKey: '' },
immichTimeoutMs: 1000,
}),
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();
});
});

View 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();
});
});

View File

@@ -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.' });
}
});

View File

@@ -4,64 +4,56 @@
*/
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
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}
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
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}
const { albumId } = await params;
const { albumId } = await params;
// Without this, `..%2f..%2fusers` resolves inside fetch() to an arbitrary
// Immich endpoint, called with the server's API key.
if (!isUuid(albumId)) {
return NextResponse.json({ error: 'Invalid album ID' }, { status: 400 });
}
// Without this, `..%2f..%2fusers` resolves inside fetch() to an arbitrary
// Immich endpoint, called with the server's API key.
if (!isUuid(albumId)) {
return NextResponse.json({ error: 'Invalid album ID' }, { status: 400 });
}
// Videos are opt-in. The hero picker feeds imageUrl()/assetPlaceholder and
// wants images only; the sort editor must show everything the public grid
// renders, or a video could never be pinned and would always fall to the tail.
const includeVideos = request.nextUrl.searchParams.get('types') === 'all';
// Videos are opt-in. The hero picker feeds imageUrl()/assetPlaceholder and
// wants images only; the sort editor must show everything the public grid
// renders, or a video could never be pinned and would always fall to the tail.
const includeVideos = request.nextUrl.searchParams.get('types') === 'all';
try {
// Via the client, not a bare fetch of `GET /albums/:id`: Immich 3.x no
// longer embeds assets in that response, so reading `album.assets` returned
// nothing (and threw outright when the key was absent). getAlbumAssetsRaw()
// pages through the metadata search and applies the album's own order, so
// this list matches what the site renders under `sort: immich`.
const all = await immich.getAlbumAssetsRaw(albumId);
try {
// Via the client, not a bare fetch of `GET /albums/:id`: Immich 3.x no
// longer embeds assets in that response, so reading `album.assets` returned
// nothing (and threw outright when the key was absent). getAlbumAssetsRaw()
// pages through the metadata search and applies the album's own order, so
// this list matches what the site renders under `sort: immich`.
const all = await immich.getAlbumAssetsRaw(albumId);
const assets = all
.filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO'))
.map((a) => ({
id: a.id,
type: a.type,
originalFileName: a.originalFileName,
fileCreatedAt: a.fileCreatedAt,
isFavorite: a.isFavorite ?? false,
}));
const assets = all
.filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO'))
.map((a) => ({
id: a.id,
type: a.type,
originalFileName: a.originalFileName,
fileCreatedAt: a.fileCreatedAt,
isFavorite: a.isFavorite ?? false,
}));
return NextResponse.json({ assets, nextPage: null });
} catch (err) {
console.error('Failed to fetch album assets:', err);
return NextResponse.json({ error: 'Failed to fetch album assets' }, { status: 500 });
}
}
return NextResponse.json({ assets, nextPage: null });
} catch (err) {
console.error('Failed to fetch album assets:', err);
return NextResponse.json({ error: 'Failed to fetch album assets' }, { status: 500 });
}
},
);

View File

@@ -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 });
}
}
});

View File

@@ -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' } },
);
}
});

View File

@@ -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 },
);
}
}
});

View File

@@ -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 });
}
}
});

View File

@@ -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 });
}
}
});

View File

@@ -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) {
findings.push(checkAlbumIds(config.albums, albums));
findings.push(checkAlbumsShared(config.albums, albums));
// 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' } },
);
}
});

View File

@@ -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 });
}
}
});

View File

@@ -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 });
}
}
});

View File

@@ -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 });
}
}
});

View File

@@ -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 });
}
}
});

View File

@@ -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' });
}
});

View File

@@ -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 });
}
}
});

View File

@@ -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,
});
}
});

View File

@@ -4,55 +4,53 @@
*/
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 });
}
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
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
}
const { id: assetId } = await params;
if (!isUuid(assetId)) {
return NextResponse.json({ error: 'Invalid asset ID' }, { status: 400 });
}
try {
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 });
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
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
}
const contentType = res.headers.get('content-type') || 'image/jpeg';
const body = res.body;
const { id: assetId } = await params;
return new NextResponse(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'private, max-age=3600',
},
});
} catch (err) {
console.error('[Admin Thumbnail] Failed to fetch:', err);
return NextResponse.json({ error: 'Failed to fetch thumbnail' }, { status: 502 });
}
}
if (!isUuid(assetId)) {
return NextResponse.json({ error: 'Invalid asset ID' }, { status: 400 });
}
try {
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 });
}
const contentType = res.headers.get('content-type') || 'image/jpeg';
const body = res.body;
return new NextResponse(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'private, max-age=3600',
},
});
} catch (err) {
console.error('[Admin Thumbnail] Failed to fetch:', err);
return NextResponse.json({ error: 'Failed to fetch thumbnail' }, { status: 502 });
}
},
);

View 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();
});
});

View File

@@ -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,41 +50,76 @@ 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 data = await getAnalytics();
data.summary.totalViews = (data.summary.totalViews || 0) + 1;
data.summary.lastUpdated = new Date().toISOString();
const run = writeQueue.then(async () => {
const data = await getAnalytics();
if (!data.days[dateKey]) {
data.days[dateKey] = { pageviews: 0, pages: {}, devices: {} };
}
data.summary.totalViews = (data.summary.totalViews || 0) + 1;
data.summary.lastUpdated = new Date().toISOString();
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;
if (!data.days[dateKey]) {
data.days[dateKey] = { pageviews: 0, pages: {}, devices: {} };
}
const dayObj = data.days[dateKey];
dayObj.pageviews = (dayObj.pageviews || 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;
await saveAnalytics(data);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: 'Failed to record tracking' }, { status: 500 });

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,12 +39,30 @@ 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
? [{ url: imageUrl(frontmatter.coverAssetId, 'preview') }]
: [];
// 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') }]
: [];
return {
title,
@@ -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>
);
}

View File

@@ -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(

View File

@@ -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>
<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>
)}
<MobileNav>
<NavLink href="/">{t.nav.home}</NavLink>
<SubpageNav />
{config.aboutEnabled && <NavLink href="/about">{t.nav.about}</NavLink>}
{config.map && <NavLink href="/map">{t.nav.map}</NavLink>}
</MobileNav>
<ThemeToggle />
</nav>
</header>