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:
71
app/api/__tests__/health-config.test.ts
Normal file
71
app/api/__tests__/health-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
137
app/api/__tests__/site-gate.test.ts
Normal file
137
app/api/__tests__/site-gate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
91
app/api/admin/__tests__/doctor-guards.test.ts
Normal file
91
app/api/admin/__tests__/doctor-guards.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
147
app/api/admin/doctor/route.ts
Normal file
147
app/api/admin/doctor/route.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user