release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The Dockerfile's HEALTHCHECK probes this route. A config that cannot be
* parsed therefore used to restart the container in a loop — at exactly the
* moment the app was still repairable through /admin, and when restarting
* cannot fix a YAML typo anyway (#519).
*/
const configOrNull = vi.fn<() => object | null>(() => ({}));
vi.mock('@/lib/config', () => ({
getConfigOrNull: () => configOrNull(),
}));
vi.mock('@/lib/env', () => ({ env: { RATE_LIMIT_RPM: 1500, TRUSTED_PROXY_HOPS: 0 } }));
vi.mock('@/lib/immich', () => ({
immich: { ping: vi.fn(async () => true) },
}));
import { NextRequest } from 'next/server';
const req = () => new NextRequest('http://localhost/api/health');
/**
* The route caches Immich's answer for ten seconds in a module variable, so
* each case needs its own module instance — otherwise the second test reads the
* first one's verdict.
*/
async function route() {
vi.resetModules();
return (await import('../health/route')).GET;
}
beforeEach(() => {
vi.clearAllMocks();
configOrNull.mockReturnValue({});
});
describe('GET /api/health', () => {
it('is healthy when the config parses and Immich answers', async () => {
const res = await (await route())(req());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.status).toBe('ok');
expect(body.config).toBe('ok');
});
it('stays healthy when the config cannot be parsed', async () => {
configOrNull.mockReturnValue(null);
const res = await (await route())(req());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.status).toBe('setup');
expect(body.config).toBe('invalid');
});
/** Immich being down is a real degradation and keeps its 503. */
it('still reports 503 when Immich is unreachable', async () => {
const GET = await route();
const { immich } = await import('@/lib/immich');
(immich.ping as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(false);
const res = await GET(req());
expect(res.status).toBe(503);
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
/**
* Route handlers do not render through the root layout, so the page-level site
* gate does not cover them. Without a check of their own, a locked site would
* still stream every photo to anyone holding an asset URL.
*
* Adding a public content route means adding a row to GATED — a route that
* forgets its guard then fails this suite instead of shipping. The OPEN list is
* the deliberate counterpart: /api/health must stay reachable (a locked site
* still has to answer a container health probe), and the login endpoint cannot
* sit behind the lock it exists to open.
*/
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283/api',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
STALE_MAX_AGE: 600,
IMAGE_CACHE_VERSION: '1',
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 1500,
TRUSTED_PROXY_HOPS: 0,
},
}));
const config = {
authSecret: 'test-secret-that-is-at-least-32-chars-long',
sitePassword: 'letmein',
rateLimitRpm: 1500,
exifOnHover: true,
map: true,
subpages: [],
albumPasswords: {},
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
}));
/** Routes read `nextUrl` and the client IP, so a bare Request is not enough. */
const request = (path: string) => new NextRequest(`http://localhost${path}`);
const params = (id: string) => ({ params: Promise.resolve({ id }) });
const GATED: { name: string; call: () => Promise<Response> }[] = [
{
name: 'GET /api/image/[id]',
call: async () =>
(await import('../image/[id]/route')).GET(
request('/api/image/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/video/[id]',
call: async () =>
(await import('../video/[id]/route')).GET(
request('/api/video/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/exif/[id]',
call: async () =>
(await import('../exif/[id]/route')).GET(
request('/api/exif/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/map',
call: async () => (await import('../map/route')).GET(request('/api/map') as never),
},
{
name: 'GET /api/og',
call: async () => (await import('../og/route')).GET(request('/api/og?title=x') as never),
},
];
describe('public content routes behind a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it.each(GATED)('$name answers 401 without a session', async ({ call }) => {
const res = await call();
expect(res.status).toBe(401);
});
it.each(GATED)('$name does not cache the refusal', async ({ call }) => {
const res = await call();
expect(res.headers.get('Cache-Control')).toBe('no-store');
});
it.each(GATED)('$name stops gating once no password is set', async ({ name, call }) => {
config.sitePassword = '';
/*
* Everything past the gate is unmocked, so a route may answer with its own
* error or throw outright (Immich is absent, /api/map wants Next's request
* store). Either is fine — the only claim here is that an open site does
* not answer 401, i.e. the guard is genuinely opt-in.
*/
const status = await call().then(
(res) => res.status,
() => 'threw past the gate',
);
expect(status, name).not.toBe(401);
});
});
describe('routes that must stay open', () => {
it('GET /api/health answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (await import('../health/route')).GET(request('/api/health') as never);
// A health probe runs without cookies; a 401 here takes the container down.
expect(res.status).not.toBe(401);
});
it('POST /api/auth is not behind the lock it opens', async () => {
config.sitePassword = 'letmein';
const res = await (
await import('../auth/route')
).POST(
new Request('http://localhost/api/auth', {
method: 'POST',
body: JSON.stringify({ slug: 'site', password: 'letmein', type: 'site' }),
}) as never,
);
expect(res.status).toBe(200);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(),
@@ -73,6 +74,14 @@ const ROUTES: {
method: 'GET',
args: () => [],
},
{
// Reports album titles, where passwords live and the proxy topology.
name: 'GET /api/admin/doctor',
path: 'doctor',
load: () => import('../doctor/route'),
method: 'GET',
args: () => [new NextRequest('http://localhost/api/admin/doctor')],
},
{
name: 'GET /api/admin/assets',
path: 'assets',

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The report names album titles, password locations and proxy topology. Like
* every other /api/admin route it has to check both guards itself — there is no
* shared middleware (see admin-guards.test.ts).
*/
const enabled = vi.fn(() => true);
const authed = vi.fn(async () => true);
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => enabled(),
isAdminAuthenticated: async () => authed(),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
getConfig: () => ({
needsCredentials: true,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [],
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: '', apiKey: '' },
immichTimeoutMs: 1000,
}),
}));
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
import { GET } from '../doctor/route';
import { NextRequest } from 'next/server';
function req(headers: Record<string, string> = {}) {
return new NextRequest('http://localhost/api/admin/doctor', { headers });
}
beforeEach(() => {
vi.clearAllMocks();
enabled.mockReturnValue(true);
authed.mockResolvedValue(true);
});
describe('GET /api/admin/doctor', () => {
it('refuses when the admin panel is disabled', async () => {
enabled.mockReturnValue(false);
expect((await GET(req())).status).toBe(403);
});
it('refuses an unauthenticated caller', async () => {
authed.mockResolvedValue(false);
expect((await GET(req())).status).toBe(401);
});
it('reports findings and the worst level', async () => {
const body = await (await GET(req())).json();
expect(Array.isArray(body.findings)).toBe(true);
expect(['ok', 'warn', 'error']).toContain(body.level);
// Missing credentials must surface as an error, not a silent pass.
expect(body.level).toBe('error');
});
/** The proxy check reads the chain off the request being served. */
it('measures the forwarded chain of the incoming request', async () => {
const body = await (await GET(req({ 'x-forwarded-for': '203.0.113.9, 10.0.0.2' }))).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('warn');
expect(proxy.title).toContain('2');
});
/**
* Next writes X-Forwarded-For itself for a direct request, so this must stay
* quiet — otherwise the warning fires on every proxy-less deployment.
*/
it('stays quiet on the single entry Next synthesises', async () => {
const body = await (await GET(req({ 'x-forwarded-for': '127.0.0.1' }))).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('ok');
});
it('trusts a lone entry that a proxy header corroborates', async () => {
const body = await (
await GET(req({ 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9' }))
).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('warn');
});
});

View File

@@ -20,11 +20,21 @@ vi.mock('@/lib/admin/yaml-service', () => ({
listBackups: vi.fn(async () => []),
}));
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(() => ({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
})),
}));
import { GET } from '../status/route';
import { readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
import { getConfig } from '@/lib/config';
const mockGallery = readGalleryYaml as unknown as ReturnType<typeof vi.fn>;
const mockSettings = readSettingsYaml as unknown as ReturnType<typeof vi.fn>;
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
async function status() {
return (await GET()).json();
@@ -35,6 +45,11 @@ describe('GET /api/admin/status — config integrity', () => {
vi.clearAllMocks();
mockGallery.mockResolvedValue({ albums: ['a'] });
mockSettings.mockResolvedValue({ title: 'Site' });
mockConfig.mockReturnValue({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
});
});
it('reports a fully configured install as valid', async () => {
@@ -58,10 +73,76 @@ describe('GET /api/admin/status — config integrity', () => {
expect((await status()).config).toMatchObject({ status: 'invalid', settings: 'invalid' });
});
// gallery.yaml, unlike settings.yaml, is genuinely required.
it('reports a missing gallery.yaml as invalid', async () => {
/**
* gallery.yaml, unlike settings.yaml, is genuinely required — but a file that
* was never created is a pending setup step, not a corrupt config. Reporting
* both as "invalid" told a fresh deployment its config was broken and offered
* a backup restore as the cure (#507).
*/
it('reports a missing gallery.yaml as pending setup, not as invalid', async () => {
mockGallery.mockResolvedValue(null);
expect((await status()).config).toMatchObject({ status: 'setup', gallery: 'missing' });
});
it('still reports a gallery.yaml that cannot be parsed as invalid', async () => {
mockGallery.mockRejectedValue(new Error('bad indentation'));
expect((await status()).config).toMatchObject({ status: 'invalid', gallery: 'invalid' });
});
});
describe('GET /api/admin/status — setup state', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGallery.mockResolvedValue({ albums: ['a'] });
mockSettings.mockResolvedValue({ title: 'Site' });
});
it('reports a finished install as complete', async () => {
mockConfig.mockReturnValue({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
});
expect((await status()).setup).toEqual({
complete: true,
credentials: 'present',
gallery: 'present',
});
});
/**
* The case from #507: credentials from the environment, no gallery.yaml. The
* admin panel must be able to tell those apart to say what is actually left
* to do instead of "System Degraded".
*/
it('separates missing credentials from a missing gallery.yaml', async () => {
mockConfig.mockReturnValue({
needsSetup: true,
needsCredentials: false,
needsGallery: true,
});
expect((await status()).setup).toEqual({
complete: false,
credentials: 'present',
gallery: 'missing',
});
});
it('reports absent credentials', async () => {
mockConfig.mockReturnValue({
needsSetup: true,
needsCredentials: true,
needsGallery: true,
});
expect((await status()).setup).toEqual({
complete: false,
credentials: 'missing',
gallery: 'missing',
});
});
});

View File

@@ -21,7 +21,10 @@ export async function GET(
}
const config = getConfig();
if (config.needsSetup) {
// 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 });
}

View File

@@ -5,6 +5,8 @@ import { getConfig } from '@/lib/config';
interface ImmichAlbumSummary {
id: string;
albumName: string;
/** Immich's own flag. Absent on older versions, which is not the same as false. */
shared?: boolean;
description: string;
albumThumbnailAssetId: string | null;
assetCount: number;
@@ -22,7 +24,10 @@ export async function GET() {
}
const config = getConfig();
if (config.needsSetup) {
// 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 });
}
@@ -51,6 +56,12 @@ export async function GET() {
createdAt: album.createdAt,
updatedAt: album.updatedAt,
isConfigured: configuredIds.has(album.id),
// Immich ignores ?shared=true and returns everything, so this list is the
// whole library rather than the shared part of it (#515). Passing the flag
// through lets the picker say which albums Immich still considers private
// — the accident-prevention the filter was supposed to give, without
// taking away the ability to publish them.
shared: album.shared,
}));
// Sort: configured first, then by name

View File

@@ -31,7 +31,10 @@ export async function GET(request: NextRequest) {
}
const config = getConfig();
if (config.needsSetup) {
// 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 });
}

View File

@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'node:fs/promises';
import path from 'node:path';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service';
import {
checkAlbumIds,
checkAlbumsShared,
checkAuthSecret,
checkImmichCalls,
checkPasswords,
checkProxyHops,
checkWritable,
countForwardedHops,
PROXY_MARKER_HEADERS,
worstLevel,
type AlbumRef,
type DoctorFinding,
type PasswordRef,
} from '@/lib/admin/doctor';
/**
* Config doctor (#491). Gathers the evidence; lib/admin/doctor.ts judges it.
*
* Everything is best-effort: a check that cannot gather its input reports that
* 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 });
}
const config = getConfig();
const findings: DoctorFinding[] = [];
// ── Secret and proxy: read off the environment and this very request ──
findings.push(checkAuthSecret(env.AUTH_SECRET || config.authSecret));
findings.push(
checkProxyHops(
config.trustedProxyHops,
countForwardedHops(request.headers.get('x-forwarded-for')),
PROXY_MARKER_HEADERS.some((h) => !!request.headers.get(h)),
),
);
// ── Immich: the three calls Folio actually depends on ────────────────
const calls: Array<{ endpoint: string; ok: boolean }> = [];
let albums: AlbumRef[] = [];
if (config.needsCredentials) {
findings.push({
id: 'immich-api',
level: 'error',
title: 'No Immich URL or API key configured',
detail: 'Set IMMICH_API_URL and IMMICH_API_KEY, or run the setup wizard at /install.',
});
} else {
const headers = { 'x-api-key': config.immich.apiKey, Accept: 'application/json' };
const call = async (endpoint: string, init?: RequestInit) => {
try {
const res = await fetch(`${config.immich.apiUrl}${endpoint}`, {
headers,
signal: AbortSignal.timeout(config.immichTimeoutMs),
...init,
});
calls.push({ endpoint, ok: res.ok });
return res.ok ? res : null;
} catch {
calls.push({ endpoint, ok: false });
return null;
}
};
await call('/server/ping');
const albumRes = await call('/albums');
await call('/search/metadata', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 1, page: 1 }),
});
if (albumRes) {
try {
albums = (await albumRes.json()) 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));
}
// ── Passwords: every place one can be configured ─────────────────────
const passwords: PasswordRef[] = [];
if (config.sitePassword) passwords.push({ label: 'Site password', value: config.sitePassword });
for (const sp of config.subpages) {
if (sp.password) passwords.push({ label: `Subpage ${sp.slug}`, value: sp.password });
}
for (const [slug, value] of Object.entries(config.albumPasswords)) {
if (value) passwords.push({ label: `Album ${slug}`, value });
}
try {
for (const entry of await listJournalEntries()) {
const password = entry.frontmatter.password;
if (password) passwords.push({ label: `Journal ${entry.slug}`, value: password });
}
} catch {
// Journal entries are optional; a missing directory is not a fault.
}
findings.push(checkPasswords(passwords));
// ── Writability of the content volume ────────────────────────────────
const contentDir = path.join(process.cwd(), 'content');
const unwritable: string[] = [];
for (const dir of ['', '.backups', 'journal']) {
const target = path.join(contentDir, dir);
try {
await fs.access(target, (await import('node:fs')).constants.W_OK);
} catch {
// A directory that does not exist yet is fine as long as its parent is
// writable — only report one that exists and refuses writes.
try {
await fs.stat(target);
unwritable.push(`content/${dir}`.replace(/\/$/, ''));
} catch {
// Not created yet.
}
}
}
findings.push(checkWritable(unwritable));
return NextResponse.json(
{ level: worstLevel(findings), findings },
{ headers: { 'Cache-Control': 'no-store' } },
);
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { cache } from '@/lib/cache';
import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
@@ -22,6 +23,7 @@ export async function GET() {
// 2. Config Health Check
let galleryValid = false;
let galleryMissing = false;
// settings.yaml is optional — getConfig() falls back to defaults when it is
// absent, so a missing file is a valid installation, not a fault. Only a file
// that exists and cannot be parsed counts as degraded: readSettingsYaml()
@@ -31,6 +33,10 @@ export async function GET() {
try {
const gallery = await readGalleryYaml();
galleryValid = !!gallery;
// A file that was never created is a pending setup step, not a corrupt
// config. Reporting both as "invalid" told a fresh deployment its config
// was broken and offered a backup restore as the cure (#507).
galleryMissing = !gallery;
} catch (err) {
console.error('[Admin API] Gallery YAML parse failed:', err);
}
@@ -60,13 +66,20 @@ export async function GET() {
}
}
const config = getConfig();
return NextResponse.json({
immich: {
status: immichOk ? 'connected' : 'disconnected',
},
setup: {
complete: !config.needsSetup,
credentials: config.needsCredentials ? 'missing' : 'present',
gallery: config.needsGallery ? 'missing' : 'present',
},
config: {
status: galleryValid && settingsValid ? 'valid' : 'invalid',
gallery: galleryValid ? 'valid' : 'invalid',
status: galleryValid && settingsValid ? 'valid' : galleryMissing ? 'setup' : 'invalid',
gallery: galleryValid ? 'valid' : galleryMissing ? 'missing' : 'invalid',
settings: settingsValid ? 'valid' : 'invalid',
},
cache: {

View File

@@ -17,7 +17,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const config = getConfig();
if (config.needsSetup) {
// 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 });
}

View File

@@ -1,10 +1,13 @@
/**
* Auth API — validates album passwords and sets auth cookies.
* POST /api/auth { slug, password }
* Auth API — validates passwords and sets auth cookies.
* POST /api/auth { slug, password, type }
*
* `type: 'site'` unlocks the whole site; its `slug` is the fixed
* SITE_AUTH_KEY, since there is only ever one site-wide gate.
*/
import { NextRequest, NextResponse } from 'next/server';
import { authenticate, isProtected } from '@/lib/auth';
import { authenticate, isProtected, type ProtectedType } from '@/lib/auth';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** Tight limit for auth attempts — 10 per minute per IP. */
@@ -46,13 +49,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 });
}
if (type !== 'subpage' && type !== 'album' && type !== 'journal') {
const TYPES: ProtectedType[] = ['subpage', 'album', 'journal', 'site'];
if (!TYPES.includes(type)) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
if (!isProtected(slug, type)) {
const typeLabel =
type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
type === 'journal'
? 'Journal entry'
: type === 'subpage'
? 'Subpage'
: type === 'site'
? 'Site'
: 'Album';
return NextResponse.json(
{
error: `${typeLabel} is not password-protected`,

View File

@@ -10,14 +10,16 @@ import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const config = getConfig();
// exifOnHover only gates the *technical* fields below. The asset description
// (EXPERIMENTAL caption) is editorial content, so the route stays reachable
// and serves captions even when EXIF display is disabled.
const exifEnabled = config.exifOnHover;
// Which groups this site publishes. The description used to be served
// unconditionally as editorial caption, which made it the one field an
// operator could not switch off — the problem behind #506. It is a group like
// any other now.
const show = config.exif;
// ── Rate limiting ──────────────────────────────────
const ip = getClientIp(request);
@@ -35,6 +37,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID
@@ -64,28 +71,38 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const exif = asset.exifInfo;
const caption = exif.description?.trim() || undefined;
return NextResponse.json(
{
...(exifEnabled
? {
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
city: exif.city,
country: exif.country,
}
: {}),
...(caption ? { description: caption } : {}),
const caption = show.caption ? exif.description?.trim() || undefined : undefined;
const payload = {
...(show.camera
? {
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
}
: {}),
...(show.settings
? {
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
}
: {}),
...(show.location ? { city: exif.city, country: exif.country } : {}),
...(caption ? { description: caption } : {}),
};
// Every group is switched off, or this asset carries nothing from the ones
// that are on. Say "no data" rather than answering with an empty object, which
// the lightbox would render as a blank panel.
if (Object.values(payload).every((value) => value === null || value === undefined)) {
return NextResponse.json({ error: 'No EXIF data' }, { status: 404 });
}
return NextResponse.json(payload, {
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
},
{
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
},
},
);
});
}

View File

@@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* EXPERIMENTAL captions (L10): the route serves the Immich asset description
* as a caption. Captions are editorial, not technical — so exifOnHover: false
* must strip the technical fields but still serve the caption, instead of the
* old blanket 403.
* The route serves the Immich asset description as a caption. It used to do so
* unconditionally — captions were treated as editorial rather than technical —
* which left it as the one field an operator could not hide (#506). Each group
* is now switchable, and `exifOnHover: false` keeps meaning "no technical EXIF"
* without touching captions.
*/
vi.mock('@/lib/config', () => ({
@@ -27,6 +28,8 @@ vi.mock('@/lib/rate-limit', () => ({
}));
import { GET } from '../[id]/route';
// Not from '@/lib/config' — that module is mocked above.
import { resolveExifDisplay, type ExifDisplayYaml } from '@/lib/config/schema';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
import { NextRequest } from 'next/server';
@@ -55,6 +58,15 @@ function makeRequest() {
const params = { params: Promise.resolve({ id: 'token123' }) };
/** A config shaped the way getConfig() builds it, for the groups under test. */
function configWith(raw?: ExifDisplayYaml, exifOnHover?: boolean) {
return {
exifOnHover: exifOnHover !== false,
exif: resolveExifDisplay(raw, exifOnHover),
rateLimitRpm: 120,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockAssetInfo.mockResolvedValue(EXIF_ASSET);
@@ -62,7 +74,7 @@ beforeEach(() => {
describe('GET /api/exif/[id] captions', () => {
it('includes the trimmed description alongside technical fields', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith());
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
@@ -72,7 +84,7 @@ describe('GET /api/exif/[id] captions', () => {
});
it('serves only the caption when exifOnHover is disabled', async () => {
mockConfig.mockReturnValue({ exifOnHover: false, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith(undefined, false));
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
@@ -83,7 +95,7 @@ describe('GET /api/exif/[id] captions', () => {
});
it('omits the description entirely when the asset has none', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith());
mockAssetInfo.mockResolvedValue({
exifInfo: { ...EXIF_ASSET.exifInfo, description: null },
});
@@ -92,4 +104,51 @@ describe('GET /api/exif/[id] captions', () => {
const body = await res.json();
expect(body).not.toHaveProperty('description');
});
it('drops the caption when the group is off', async () => {
mockConfig.mockReturnValue(configWith({ caption: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('description');
expect(body.model).toBe('X-T5');
});
it('drops the location without touching the camera', async () => {
mockConfig.mockReturnValue(configWith({ location: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('city');
expect(body).not.toHaveProperty('country');
expect(body.model).toBe('X-T5');
expect(body.iso).toBe(200);
});
it('drops the exposure settings without touching the camera', async () => {
mockConfig.mockReturnValue(configWith({ settings: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('iso');
expect(body).not.toHaveProperty('fNumber');
expect(body.lensModel).toBe('XF 35mm');
});
/** An empty object would render as a blank panel rather than "no data". */
it('answers 404 when every group is switched off', async () => {
mockConfig.mockReturnValue(
configWith({ camera: false, settings: false, location: false, caption: false }),
);
const res = await GET(makeRequest(), params);
expect(res.status).toBe(404);
});
it('answers 404 when the enabled groups carry nothing for this asset', async () => {
mockConfig.mockReturnValue(configWith({ camera: false, settings: false, caption: false }));
mockAssetInfo.mockResolvedValue({
exifInfo: { ...EXIF_ASSET.exifInfo, city: null, country: null },
});
const res = await GET(makeRequest(), params);
expect(res.status).toBe(404);
});
});

View File

@@ -8,7 +8,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { immich } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { getConfigOrNull } from '@/lib/config';
const startTime = Date.now();
@@ -21,7 +22,11 @@ const CACHE_DURATION_MS = 10_000;
export async function GET(request: NextRequest) {
const ip = getClientIp(request);
const { rateLimitRpm } = getConfig();
// env, not getConfig(): a health check that cannot answer while the content
// config is unparsable is worse than useless — the Dockerfile probes this
// route, so a container with a broken gallery.yaml would restart in a loop at
// exactly the moment the app is still repairable through /admin (#519).
const rateLimitRpm = env.RATE_LIMIT_RPM;
const { success, remaining, resetAt } = checkRateLimit(`health:${ip}`, rateLimitRpm);
if (!success) {
@@ -38,6 +43,28 @@ export async function GET(request: NextRequest) {
);
}
/*
* A config that cannot be parsed is not a reason to kill the container.
* Restarting cannot fix a YAML typo, the app is still serving the setup
* screen, and /admin — where the fix happens — is still reachable. Reporting
* it as unhealthy made the Dockerfile's HEALTHCHECK restart the container in
* a loop at exactly the moment it was still repairable (#519).
*
* Immich itself is not consulted here: the ping reads the same config and
* would answer "unreachable" for a reason that has nothing to do with Immich.
*/
if (!getConfigOrNull()) {
return NextResponse.json(
{
status: 'setup',
config: 'invalid',
immich: 'unknown',
uptime: Math.floor((Date.now() - startTime) / 1000),
},
{ status: 200, headers: { 'Cache-Control': 'no-store' } },
);
}
const now = Date.now();
if (!cachedImmichOkPromise || now - lastCheckTime > CACHE_DURATION_MS) {
cachedImmichOkPromise = immich.ping();
@@ -56,6 +83,7 @@ export async function GET(request: NextRequest) {
const body = {
status: immichOk ? 'ok' : 'degraded',
config: 'ok',
immich: immichOk ? 'connected' : 'unreachable',
uptime: Math.floor((Date.now() - startTime) / 1000),
};

View File

@@ -13,6 +13,7 @@ import { resolveImageSize } from '@/lib/imageSize';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
@@ -38,6 +39,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID

View File

@@ -92,8 +92,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Could not reach Immich at this URL' }, { status: 502 });
}
// The covers of the albums just chosen, as the home page hero. One extra
// request during install, and it is the difference between a portfolio that
// opens with a photograph and one that opens with a list of names (#518).
const hero = albums.length ? await fetchAlbumCovers(apiBase, apiKey, albums) : [];
try {
await completeInstall({
hero,
apiUrl,
apiKey,
siteTitle: typeof body.siteTitle === 'string' ? body.siteTitle : undefined,
@@ -115,3 +121,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Failed to write configuration files' }, { status: 500 });
}
}
/** How many covers the home page carousel is seeded with. */
const HERO_SEED_MAX = 5;
/**
* Cover asset IDs for the given albums, in the order they were chosen.
*
* Best effort by design: this runs while the wizard is finishing, and a hero
* image is a nicety. Anything that goes wrong here leaves `hero: []`, which is
* exactly what the wizard wrote before.
*/
async function fetchAlbumCovers(
apiBase: string,
apiKey: string,
albumIds: string[],
): Promise<string[]> {
try {
const res = await fetch(`${apiBase}/albums`, {
headers: { 'x-api-key': apiKey, Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return [];
const all = (await res.json()) as Array<{ id: string; albumThumbnailAssetId: string | null }>;
const covers = new Map(all.map((a) => [a.id, a.albumThumbnailAssetId]));
return albumIds
.map((id) => covers.get(id))
.filter((assetId): assetId is string => !!assetId && UUID_REGEX.test(assetId))
.slice(0, HERO_SEED_MAX);
} catch {
return [];
}
}

View File

@@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls';
import { isAuthenticated } from '@/lib/auth';
import { isAuthenticated, siteLockResponse } from '@/lib/auth';
import { getMapData } from '@/lib/mapService';
import { ImmichUnavailableError } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -35,6 +35,11 @@ export async function GET(request: NextRequest) {
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const config = getConfig();
if (!config.map) {

View File

@@ -10,6 +10,7 @@ import { ImageResponse } from 'next/og';
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
/**
* OG image rendering runs satori + resvg per request and each unique ?title
@@ -34,6 +35,11 @@ export async function GET(request: NextRequest) {
});
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { searchParams } = request.nextUrl;
const title = (searchParams.get('title') || 'Gallery').slice(0, 200);
const subtitle = (searchParams.get('subtitle') || '').slice(0, 100);

View File

@@ -14,6 +14,7 @@ import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
@@ -32,6 +33,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID