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

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