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