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

+39
View File
@@ -0,0 +1,39 @@
import 'dotenv/config';
import { hash } from '@node-rs/argon2';
import postgres from 'postgres';
import { createInterface } from 'node:readline/promises';
const username = process.argv[2]?.trim().toLowerCase();
if (!username || !/^[a-z0-9._-]{3,80}$/.test(username))
throw new Error('Aufruf: npm run admin:create -- BENUTZERNAME (3–80 Zeichen)');
async function passwordInput(): Promise<string> {
if (!process.stdin.isTTY) {
let input = '';
for await (const chunk of process.stdin) input += chunk;
return input.trimEnd();
}
process.stdout.write('Neues Passwort (mind. 12 Zeichen, Eingabe verborgen): ');
const rl = createInterface({ input: process.stdin, output: process.stdout });
// Disable terminal echo while readline receives characters.
const output = rl as unknown as { _writeToOutput: (s: string) => void };
output._writeToOutput = () => {};
const value = await rl.question('');
rl.close();
process.stdout.write('\n');
return value;
}
const password = await passwordInput();
if (password.length < 12 || password.length > 200)
throw new Error('Passwort muss 12–200 Zeichen enthalten.');
const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
try {
const hashed = await hash(password, {
algorithm: 2,
memoryCost: 65536,
timeCost: 3,
parallelism: 1,
});
await sql`INSERT INTO admins (username, password) VALUES (${username}, ${hashed})`;
console.log(`Administrator ${username} erstellt.`);
} finally {
await sql.end();
}
+18
View File
@@ -0,0 +1,18 @@
import 'dotenv/config';
import { readdir, readFile } from 'node:fs/promises';
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
try {
await sql.begin(async (tx) => {
await tx`SELECT pg_advisory_xact_lock(7218194)`;
await tx`CREATE TABLE IF NOT EXISTS migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`;
for (const name of (await readdir('migrations')).filter((f) => f.endsWith('.sql')).sort()) {
if ((await tx`SELECT name FROM migrations WHERE name = ${name}`).length) continue;
await tx.unsafe(await readFile(`migrations/${name}`, 'utf8'));
await tx`INSERT INTO migrations (name) VALUES (${name})`;
console.log(`Migration angewendet: ${name}`);
}
});
} finally {
await sql.end();
}
+97
View File
@@ -0,0 +1,97 @@
import { z } from 'zod';
import { mkdir, writeFile } from 'node:fs/promises';
import { collectionSchema, recipeSchema } from '../src/lib/recipe';
import { exportRecipes } from '../src/lib/import';
import { examples } from '../tests/fixtures';
await mkdir('public/examples', { recursive: true });
await writeFile(
'public/examples/hamham.schema.json',
JSON.stringify(z.toJSONSchema(collectionSchema, { target: 'draft-2020-12' }), null, 2),
);
const r = recipeSchema.parse({
title: 'Serbische Pljeskavica',
subtitle: 'Saftig gegrillt, mit Ajvar und frischen Zwiebeln',
description:
'Ein Klassiker vom Balkan: würzige Faschierte-Laibchen, außen kräftig geröstet und innen saftig. Dazu passen Lepinja, Ajvar und ein knackiger Salat.',
cuisine: 'Serbien',
categories: ['Für Gäste'],
tags: ['Grillen', 'Familienliebling'],
difficulty: 'Einfach',
prepMinutes: 20,
cookMinutes: 12,
restMinutes: 60,
servings: 4,
published: false,
ingredients: [
{ id: 'fleisch', name: 'Rinderfaschiertes', amount: 800, unit: 'g', section: 'Pljeskavica' },
{
id: 'zwiebel',
name: 'Zwiebel',
amount: 1,
unit: 'Stück',
note: 'sehr fein gewürfelt',
section: 'Pljeskavica',
},
{ id: 'paprika', name: 'Paprikapulver edelsüß', amount: 2, unit: 'TL', section: 'Pljeskavica' },
{ id: 'salz', name: 'Salz', amount: 10, unit: 'g', section: 'Pljeskavica' },
{
id: 'pfeffer',
name: 'Schwarzer Pfeffer',
amount: null,
unit: '',
note: 'nach Geschmack',
section: 'Pljeskavica',
},
{ id: 'wasser', name: 'Mineralwasser', amount: 60, unit: 'ml', section: 'Pljeskavica' },
{
id: 'ajvar',
name: 'Ajvar',
amount: 120,
unit: 'g',
section: 'Zum Servieren',
optional: true,
},
{ id: 'brot', name: 'Lepinja', amount: 4, unit: 'Stück', section: 'Zum Servieren' },
],
steps: [
{
id: 'mischen',
title: 'Die Mischung vorbereiten',
text: '{{fleisch}}, {{zwiebel}}, {{paprika}}, {{salz}} und {{wasser}} gründlich verkneten. Mit {{pfeffer}} abschmecken.',
ingredients: ['fleisch', 'zwiebel', 'paprika', 'salz', 'wasser', 'pfeffer'],
},
{
id: 'ruhen',
title: 'Geschmack entwickeln lassen',
text: 'Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.',
timers: [{ id: 'ruhezeit', label: 'Im Kühlschrank ruhen', seconds: 3600 }],
},
{
id: 'formen',
title: 'Flache Laibchen formen',
text: 'Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.',
},
{
id: 'grillen',
title: 'Kräftig grillen',
text: 'Grill oder Grillpfanne vorheizen. Laibchen von beiden Seiten je etwa fünf bis sechs Minuten braten. Faschiertes vollständig durchgaren: im Kern mindestens 70 °C für zwei Minuten erreichen.',
timers: [
{ id: 'erste-seite', label: 'Erste Seite', seconds: 360 },
{ id: 'zweite-seite', label: 'Zweite Seite', seconds: 360 },
],
},
{
id: 'servieren',
title: 'Ab an den Tisch',
text: 'In {{brot}} mit {{ajvar}} servieren. Nach Wunsch frische Zwiebelringe dazu reichen.',
ingredients: ['brot', 'ajvar'],
},
],
tips: 'Nicht während des Bratens flachdrücken – so bleibt die Pljeskavica saftig. Für Schärfe etwas Chiliflocken zur Masse geben.',
storage:
'Reste rasch abkühlen, abgedeckt im Kühlschrank aufbewahren und innerhalb von zwei Tagen verbrauchen. Vor dem Essen gründlich erhitzen.',
});
await writeFile('public/examples/pljeskavica.json', exportRecipes([r], 'json'));
await writeFile('public/examples/pljeskavica.xml', exportRecipes([r], 'xml'));
await writeFile('public/examples/kochbuch.json', exportRecipes(examples, 'json'));
console.log('JSON Schema und vollständige JSON/XML-Beispiele erzeugt.');