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:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user