feat(core): add secure recipe APIs, precise scaling and versioned imports

This commit is contained in:
OpenCode committed 2026-10-24 15:40:44 +02:00
commit 5d71d32e20
31 files changed
+6293

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
import { cookies } from 'next/headers';
import { hash, verify } from '@node-rs/argon2';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, sql } from '@/db';
import { admins, sessions, recipes, settings, media } from '@/db/schema';
import {
admin,
requireAdmin,
checkOrigin,
ApiError,
apiError,
jsonBody,
digest,
newSession,
rateLimit,
} from '@/lib/server';
import { recipeSchema } from '@/lib/recipe';
import { getRecipe, listRecipes, saveRecipe, serialize, titleKey } from '@/lib/repository';
import { parseImport, exportRecipes } from '@/lib/import';
import { mkdir, writeFile, unlink } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type Context = { params: Promise<{ path: string[] }> };
const loginSchema = z.object({
username: z.string().trim().toLowerCase().min(3).max(80),
password: z.string().min(1).max(200),
});
const uuid = z.string().uuid();
async function handle(req: Request, ctx: Context) {
try {
const parts = (await ctx.params).path,
route = parts.join('/'),
method = req.method,
url = new URL(req.url);
if (!['GET', 'HEAD'].includes(method)) checkOrigin(req);
if (route === 'health' && method === 'GET') {
await sql`SELECT 1`;
return Response.json({ status: 'ok' });
}
if (route === 'auth/login' && method === 'POST') {
const body = loginSchema.parse(await jsonBody(req));
const ip =
process.env.TRUST_PROXY === 'true'
? (req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown')
: 'direct';
await rateLimit(`ip:${digest(ip)}`, 50);
await rateLimit(`user:${digest(body.username)}`, 8);
const [user] = await db.select().from(admins).where(eq(admins.username, body.username));
// Equal expensive work for unknown accounts to reduce timing-based enumeration.
const valid = user
? await verify(user.password, body.password)
: (await hash(body.password, { memoryCost: 65536, timeCost: 3 }), false);
if (!user || !valid) throw new ApiError(401, 'Benutzername oder Passwort ist falsch.');
await sql`DELETE FROM sessions WHERE expires < now()`;
await sql`DELETE FROM login_limits WHERE reset < now()`;
await newSession(user.id);
return Response.json({ username: user.username });
}
if (route === 'auth/me' && method === 'GET') return Response.json({ user: await admin() });
if (route === 'auth/logout' && method === 'POST') {
const c = await cookies(),
token = c.get('hamham_session')?.value;
if (token) await db.delete(sessions).where(eq(sessions.token, digest(token)));
c.delete('hamham_session');
return Response.json({ ok: true });
}
if (route === 'recipes' && method === 'GET')
return Response.json(
await listRecipes(url.searchParams.get('admin') === '1' && !!(await admin())),
);
if (parts[0] === 'recipes' && parts.length === 2 && method === 'GET') {
const recipe = await getRecipe(uuid.parse(parts[1]), !!(await admin()));
if (!recipe) throw new ApiError(404, 'Rezept nicht gefunden.');
return Response.json(recipe);
}
const user = await requireAdmin();
if (route === 'auth/password' && method === 'POST') {
const body = z
.object({ current: z.string().max(200), password: z.string().min(12).max(200) })
.parse(await jsonBody(req));
await rateLimit(`password:${user.id}`, 8);
const [row] = await db.select().from(admins).where(eq(admins.id, user.id));
if (!(await verify(row.password, body.current)))
throw new ApiError(400, 'Aktuelles Passwort ist falsch.');
const password = await hash(body.password, {
algorithm: 2,
memoryCost: 65536,
timeCost: 3,
parallelism: 1,
});
await db.transaction(async (tx) => {
await tx.update(admins).set({ password }).where(eq(admins.id, user.id));
await tx.delete(sessions).where(eq(sessions.adminId, user.id));
});
await newSession(user.id);
return Response.json({ ok: true });
}
if (route === 'recipes' && method === 'POST')
return Response.json(await saveRecipe(recipeSchema.parse(await jsonBody(req))), {
status: 201,
});
if (parts[0] === 'recipes' && parts.length === 2) {
const id = uuid.parse(parts[1]);
if (method === 'PUT') {
const body = await jsonBody(req);
return Response.json(await saveRecipe(recipeSchema.parse(body), id, body.updatedAt));
}
if (method === 'DELETE') {
const rows = await db.delete(recipes).where(eq(recipes.id, id)).returning();
if (!rows.length) throw new ApiError(404, 'Rezept nicht gefunden.');
return Response.json({ ok: true });
}
}
if (route === 'import' && method === 'POST') {
const body = z
.object({ raw: z.string(), format: z.enum(['json', 'xml']), preview: z.boolean() })
.parse(await jsonBody(req));
let parsed;
try {
parsed = parseImport(body.raw, body.format);
} catch (e) {
if (e instanceof z.ZodError) throw e;
throw new ApiError(422, e instanceof Error ? e.message : 'Ungültiger Import.');
}
const existing = await listRecipes(true);
const keys = new Set<string>();
const preview = parsed.recipes.map((r) => {
const key = titleKey(r.title);
const duplicate =
existing.some((e) => e.id === r.id || titleKey(e.title) === key) || keys.has(key);
keys.add(key);
return { ...r, duplicate };
});
if (body.preview) return Response.json(preview);
if (preview.some((r) => r.duplicate))
throw new ApiError(
409,
'Duplikate erkannt. Bitte IDs oder Titel prüfen. Es wurde nichts importiert.',
);
const result = await db.transaction(async (tx) => {
const inserted = [];
for (const r of parsed.recipes) {
const [row] = await tx
.insert(recipes)
.values({
id: r.id,
title: r.title,
titleKey: titleKey(r.title),
published: r.published,
data: r,
createdAt: r.createdAt ? new Date(r.createdAt) : undefined,
updatedAt: r.updatedAt ? new Date(r.updatedAt) : new Date(),
})
.returning();
inserted.push(serialize(row));
}
return inserted;
});
return Response.json(result, { status: 201 });
}
if (route === 'export' && method === 'GET') {
const format = url.searchParams.get('format') === 'xml' ? 'xml' : 'json',
id = url.searchParams.get('id');
const data = id
? [await getRecipe(uuid.parse(id), true)].filter((x) => x !== null)
: await listRecipes(true);
return new Response(exportRecipes(data, format), {
headers: {
'Content-Type':
format === 'xml' ? 'application/xml; charset=utf-8' : 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="hamham-rezepte.${format}"`,
'Cache-Control': 'no-store',
},
});
}
if (route === 'settings') {
if (method === 'GET')
return Response.json(
Object.fromEntries((await db.select().from(settings)).map((r) => [r.key, r.value])),
);
if (method === 'PUT') {
const body = z
.object({
categories: z.array(z.string().trim().min(1).max(60)).max(100),
tags: z.array(z.string().trim().min(1).max(60)).max(200),
household: z.string().trim().min(1).max(80),
})
.parse(await jsonBody(req));
await db.transaction(async (tx) => {
for (const [key, value] of Object.entries(body))
await tx
.insert(settings)
.values({ key, value })
.onConflictDoUpdate({ target: settings.key, set: { value } });
});
return Response.json(body);
}
}
if (route === 'media' && method === 'GET')
return Response.json((await db.select().from(media)).map((m) => `/media/${m.name}`));
if (route === 'media' && method === 'POST') {
const limit = 10 * 1024 * 1024;
if (
!['image/jpeg', 'image/png', 'image/webp'].includes(req.headers.get('content-type') ?? '')
)
throw new ApiError(415, 'Bitte JPEG, PNG oder WebP hochladen.');
const reader = req.body?.getReader();
if (!reader) throw new ApiError(400, 'Bild fehlt.');
let size = 0;
const chunks: Uint8Array[] = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
size += value.length;
if (size > limit) {
await reader.cancel();
throw new ApiError(413, 'Bild darf höchstens 10 MB groß sein.');
}
chunks.push(value);
}
let buffer: Buffer;
try {
buffer = await sharp(Buffer.concat(chunks), { limitInputPixels: 40000000 })
.rotate()
.resize(2400, 2400, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 86 })
.toBuffer();
} catch {
throw new ApiError(422, 'Bild konnte nicht sicher gelesen werden.');
}
const name = `${crypto.randomUUID()}.webp`,
dir = process.env.UPLOAD_DIR ?? './uploads';
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, name), buffer, { flag: 'wx' });
try {
await db.insert(media).values({ name });
} catch (e) {
await unlink(path.join(dir, name));
throw e;
}
return Response.json({ url: `/media/${name}` }, { status: 201 });
}
if (route === 'media' && method === 'DELETE') {
const { name } = z
.object({ name: z.string().regex(/^[a-f0-9-]{36}\.webp$/) })
.parse(await jsonBody(req));
if (
(await listRecipes(true)).some(
(r) => r.image === `/media/${name}` || r.images.includes(`/media/${name}`),
)
)
throw new ApiError(409, 'Das Bild wird noch von einem Rezept verwendet.');
await unlink(
path.join(/* turbopackIgnore: true */ process.env.UPLOAD_DIR ?? './uploads', name),
).catch((e: NodeJS.ErrnoException) => {
if (e.code !== 'ENOENT') throw e;
});
await db.delete(media).where(eq(media.name, name));
return Response.json({ ok: true });
}
throw new ApiError(404, 'Endpunkt nicht gefunden.');
} catch (e) {
return apiError(e);
}
}
export const GET = handle,
POST = handle,
PUT = handle,
DELETE = handle;
+23
View File
@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
export async function GET(_req: Request, ctx: { params: Promise<{ name: string }> }) {
const { name } = await ctx.params;
if (!/^[a-f0-9-]{36}\.webp$/.test(name)) return new Response(null, { status: 404 });
try {
const buffer = await readFile(
/* turbopackIgnore: true */ path.join(
/* turbopackIgnore: true */ process.env.UPLOAD_DIR ?? './uploads',
name,
),
);
return new Response(buffer, {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-Content-Type-Options': 'nosniff',
},
});
} catch {
return new Response(null, { status: 404 });
}
}