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:
68
app/api/admin/__tests__/about-backup.test.ts
Normal file
68
app/api/admin/__tests__/about-backup.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
77
app/api/admin/__tests__/settings-write.test.ts
Normal file
77
app/api/admin/__tests__/settings-write.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' } },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' } },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user