162 lines
6.1 KiB
TypeScript
162 lines
6.1 KiB
TypeScript
|
|
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 });
|
||
|
|
}
|
||
|
|
});
|