Files
immich-folio/app/api/admin/pages/[slug]/route.ts

162 lines
6.1 KiB
TypeScript
Raw Normal View History

import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { isValidSlug, parseJournalMarkdown, sanitizeSlug } from '@/lib/journal';
import { describeCollision, pageSlugCollision } from '@/lib/pages';
import { hashFrontmatterPassword } from '@/lib/admin/passwordHashing';
import { deletePage, readPage, savePage } from '@/lib/admin/pages-service';
import {
VersionConflictError,
baseVersionFrom,
conflictResponse,
etag,
} from '@/lib/admin/contentVersion';
import { takenPageSlugs } from '@/lib/admin/pageSlugs';
import { updatePageRefs, type GalleryVersionChange } from '@/lib/admin/pageRefs';
interface RouteContext {
params: Promise<{ slug: string }>;
}
/**
* Follow a page rename or delete in gallery.yaml's menu. Runs after the page
* file already moved, so a failure here (gallery.yaml edited into invalid
* YAML, a write error) must not turn the answer into a 500: the admin would be
* told nothing was saved, keep the old slug, and hit a 404 on every retry.
* The page change stands; the menu is left as it was and the admin is told.
*/
async function followInMenu(
from: string,
to: string | null,
): Promise<{ galleryVersion: GalleryVersionChange | null; warning?: string }> {
try {
return { galleryVersion: await updatePageRefs(from, to) };
} catch (err) {
console.error(`[Admin API] Page "${from}" changed, but gallery.yaml was not updated:`, err);
return {
galleryVersion: null,
warning:
to === null
? `Page deleted, but its menu entry could not be removed from gallery.yaml. Remove "page: ${from}" there by hand.`
: `Page renamed, but the menu in gallery.yaml could not be updated. Change "page: ${from}" to "page: ${to}" there by hand.`,
};
}
}
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 });
}
try {
const page = await readPage(slug);
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
// `version` goes back in If-Match on save (#601).
const version = page.version;
return NextResponse.json({ page, version }, { headers: { ETag: etag(version) } });
} catch (err) {
console.error(`[Admin API] Failed to read page "${slug}":`, err);
return NextResponse.json({ error: 'Failed to read page' }, { status: 500 });
}
});
/**
* Save a page. `newSlug` renames it: the slug is checked like a new one, the
* menu reference in gallery.yaml follows, and old links break — the admin
* says so before sending it. There are no automatic redirects (#722).
*/
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 });
}
const body = await request.json().catch(() => null);
const rawMarkdown = body?.rawMarkdown;
if (typeof rawMarkdown !== 'string') {
return NextResponse.json({ error: 'Missing rawMarkdown content' }, { status: 400 });
}
try {
const current = await readPage(slug);
if (!current) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
// Refuse what the parser cannot read before anything is written.
parseJournalMarkdown(rawMarkdown);
const targetSlug =
typeof body.newSlug === 'string' && body.newSlug ? sanitizeSlug(body.newSlug) : slug;
if (targetSlug !== slug) {
const collision = pageSlugCollision(targetSlug, await takenPageSlugs());
if (collision) {
return NextResponse.json(
{ error: describeCollision(targetSlug, collision) },
{ status: 409 },
);
}
if (await readPage(targetSlug)) {
return NextResponse.json(
{ error: `A page "${targetSlug}" already exists.` },
{ status: 409 },
);
}
}
// Stored hashed (#690); the current file supplies the hash to keep.
const markdown = await hashFrontmatterPassword(rawMarkdown, current.rawMarkdown);
// A rename is checked against the file the editor loaded, the old slug.
const version = await savePage(targetSlug, markdown, {
baseVersion: baseVersionFrom(request),
fromSlug: slug,
});
const { galleryVersion, warning } =
targetSlug !== slug
? await followInMenu(slug, targetSlug)
: { galleryVersion: null, warning: undefined };
revalidatePath('/', 'layout');
return NextResponse.json({
success: true,
page: await readPage(targetSlug),
menuRenamed: galleryVersion !== null,
// So the open page builder can follow the menu rewrite (#601).
galleryVersion,
version,
...(warning ? { warning } : {}),
});
} catch (err) {
if (err instanceof VersionConflictError) return conflictResponse(err.currentVersion);
console.error(`[Admin API] Failed to save page "${slug}":`, err);
return NextResponse.json({ error: 'Failed to save page' }, { status: 500 });
}
});
/**
* Delete a page. A `.deleted.bak` copy is kept, and a menu reference is
* removed from gallery.yaml (backed up as well), so the menu does not keep
* pointing at nothing.
*/
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 });
}
try {
const deleted = await deletePage(slug);
if (!deleted) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
const { galleryVersion, warning } = await followInMenu(slug, null);
revalidatePath('/', 'layout');
return NextResponse.json({
success: true,
deletedSlug: slug,
removedFromMenu: galleryVersion !== null,
galleryVersion,
...(warning ? { warning } : {}),
});
} catch (err) {
console.error(`[Admin API] Failed to delete page "${slug}":`, err);
return NextResponse.json({ error: 'Failed to delete page' }, { status: 500 });
}
});