feat(core): add secure recipe APIs, precise scaling and versioned imports
This commit is contained in:
commit
5d71d32e20
31 files changed
+6293
No files matched your search
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user