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

+10
View File
@@ -0,0 +1,10 @@
node_modules/
.next/
.env
.env.*.local
coverage/
playwright-report/
test-results/
uploads/
*.tsbuildinfo
.DS_Store
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
package-lock.json
public/examples
test-results
playwright-report
docs/screenshots
uploads
+1
View File
@@ -0,0 +1 @@
{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE recipes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), title text NOT NULL,
title_key text NOT NULL UNIQUE, published boolean NOT NULL DEFAULT false,
data jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX recipes_published_created ON recipes (published, created_at DESC);
CREATE INDEX recipes_data ON recipes USING gin (data);
CREATE TABLE admins (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), username text NOT NULL UNIQUE, password text NOT NULL);
CREATE TABLE sessions (token text PRIMARY KEY, admin_id uuid NOT NULL REFERENCES admins(id) ON DELETE CASCADE, expires timestamptz NOT NULL);
CREATE INDEX sessions_expires ON sessions (expires);
CREATE TABLE login_limits (key text PRIMARY KEY, count integer NOT NULL, reset timestamptz NOT NULL);
CREATE TABLE settings (key text PRIMARY KEY, value jsonb NOT NULL);
INSERT INTO settings VALUES ('categories', '["Alltagsküche", "Vegetarisch", "Für Gäste", "Süßes"]'), ('tags', '["Schnell", "Familienliebling", "Meal Prep"]'), ('household', '"Unsere Küche"');
CREATE TABLE media (name text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('next').NextConfig} */
const config = {
poweredByHeader: false,
serverExternalPackages: ['@node-rs/argon2', 'postgres', 'sharp'],
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'same-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Content-Security-Policy',
value:
"default-src 'self'; script-src 'self' 'unsafe-inline'" +
(process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '') +
"; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; worker-src 'self'",
},
],
},
];
},
};
export default config;
+3697
View File
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "hamham",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"dev": "next dev --hostname 0.0.0.0",
"build": "next build",
"start": "next start --hostname 0.0.0.0",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test",
"test:docker": "tsx scripts/verify-docker.ts",
"db:migrate": "tsx scripts/migrate.ts",
"admin:create": "tsx scripts/admin.ts",
"schema:generate": "tsx scripts/schema.ts"
},
"dependencies": {
"@fontsource/dm-sans": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@node-rs/argon2": "^2.2.2",
"decimal.js": "^10.6.0",
"dotenv": "^18.0.9",
"drizzle-orm": "^0.45.4",
"fast-xml-parser": "^5.11.2",
"lucide-react": "^1.61.0",
"motion": "^14.6.0",
"next": "^16.4.3",
"postgres": "^3.4.9",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"sharp": "^0.35.5",
"tsx": "^4.23.15",
"zod": "^4.6.7"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"prettier": "^3.9.10",
"typescript": "^7.0.2",
"vitest": "^5.0.5"
}
}
+349
View File
@@ -0,0 +1,349 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 1
},
"recipes": {
"minItems": 1,
"maxItems": 100,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"title": {
"type": "string",
"minLength": 2,
"maxLength": 180
},
"subtitle": {
"default": "",
"type": "string",
"maxLength": 200
},
"description": {
"default": "",
"type": "string",
"maxLength": 4000
},
"image": {
"default": "",
"anyOf": [
{
"type": "string",
"pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$"
},
{
"type": "string",
"const": ""
}
]
},
"images": {
"default": [],
"maxItems": 20,
"type": "array",
"items": {
"type": "string",
"pattern": "^\\/(?:media\\/[a-f0-9-]+\\.webp|images\\/[a-z0-9-]+\\.(?:webp|jpg|svg))$"
}
},
"categories": {
"default": [],
"maxItems": 20,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 60
}
},
"tags": {
"default": [],
"maxItems": 30,
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 60
}
},
"cuisine": {
"default": "",
"type": "string",
"maxLength": 80
},
"difficulty": {
"default": "Einfach",
"type": "string",
"enum": [
"Einfach",
"Mittel",
"Anspruchsvoll"
]
},
"prepMinutes": {
"default": 0,
"type": "integer",
"minimum": 0,
"maximum": 10080
},
"cookMinutes": {
"default": 0,
"type": "integer",
"minimum": 0,
"maximum": 10080
},
"restMinutes": {
"default": 0,
"type": "integer",
"minimum": 0,
"maximum": 10080
},
"servings": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 10000
},
"ingredients": {
"minItems": 1,
"maxItems": 200,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,80}$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"amount": {
"default": null,
"anyOf": [
{
"type": "number",
"exclusiveMinimum": 0,
"maximum": 1000000
},
{
"type": "null"
}
]
},
"unit": {
"default": "",
"type": "string",
"enum": [
"g",
"kg",
"ml",
"l",
"Stück",
"TL",
"EL",
"Prise",
"Bund",
""
]
},
"section": {
"default": "",
"type": "string",
"maxLength": 80
},
"note": {
"default": "",
"type": "string",
"maxLength": 200
},
"optional": {
"default": false,
"type": "boolean"
}
},
"required": [
"id",
"name",
"amount",
"unit",
"section",
"note",
"optional"
],
"additionalProperties": false
}
},
"steps": {
"minItems": 1,
"maxItems": 100,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,80}$"
},
"title": {
"default": "",
"type": "string",
"maxLength": 160
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 6000
},
"ingredients": {
"default": [],
"maxItems": 100,
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,80}$"
}
},
"timers": {
"default": [],
"maxItems": 10,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,80}$"
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"seconds": {
"type": "integer",
"minimum": 1,
"maximum": 172800
}
},
"required": [
"id",
"label",
"seconds"
],
"additionalProperties": false
}
}
},
"required": [
"id",
"title",
"text",
"ingredients",
"timers"
],
"additionalProperties": false
}
},
"tips": {
"default": "",
"type": "string",
"maxLength": 6000
},
"storage": {
"default": "",
"type": "string",
"maxLength": 3000
},
"nutrition": {
"default": null,
"anyOf": [
{
"type": "object",
"properties": {
"kcal": {
"type": "number",
"minimum": 0
},
"protein": {
"type": "number",
"minimum": 0
},
"carbs": {
"type": "number",
"minimum": 0
},
"fat": {
"type": "number",
"minimum": 0
}
},
"required": [
"kcal",
"protein",
"carbs",
"fat"
],
"additionalProperties": false
},
{
"type": "null"
}
]
},
"published": {
"default": false,
"type": "boolean"
},
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"title",
"subtitle",
"description",
"image",
"images",
"categories",
"tags",
"cuisine",
"difficulty",
"prepMinutes",
"cookMinutes",
"restMinutes",
"servings",
"ingredients",
"steps",
"tips",
"storage",
"nutrition",
"published"
],
"additionalProperties": false
}
}
},
"required": [
"version",
"recipes"
],
"additionalProperties": false
}
+430
View File
@@ -0,0 +1,430 @@
{
"version": 1,
"recipes": [
{
"id": "10000000-0000-4000-8000-000000000001",
"title": "Grüne Pesto-Pasta",
"subtitle": "Ein Teller Sommer, ganz ohne Eile.",
"description": "Frisches Basilikum, geröstete Pinienkerne und ein Hauch Zitrone. Unsere liebste Pasta für Tage, an denen es einfach gut sein soll.",
"image": "/images/kitchen.jpg",
"images": [],
"categories": [
"Alltagsküche",
"Vegetarisch"
],
"tags": [
"Schnell",
"Familienliebling"
],
"cuisine": "Italien",
"difficulty": "Einfach",
"prepMinutes": 10,
"cookMinutes": 12,
"restMinutes": 0,
"servings": 2,
"ingredients": [
{
"id": "pasta",
"name": "Pasta",
"amount": 200,
"unit": "g",
"section": "Für die Pasta",
"note": "",
"optional": false
},
{
"id": "basilikum",
"name": "Basilikum",
"amount": 40,
"unit": "g",
"section": "Für das Pesto",
"note": "",
"optional": false
},
{
"id": "kerne",
"name": "Pinienkerne",
"amount": 30,
"unit": "g",
"section": "Für das Pesto",
"note": "",
"optional": false
},
{
"id": "oel",
"name": "Olivenöl",
"amount": 60,
"unit": "ml",
"section": "Für das Pesto",
"note": "",
"optional": false
},
{
"id": "kaese",
"name": "Hartkäse mit mikrobiellem Lab",
"amount": 40,
"unit": "g",
"section": "Für das Pesto",
"note": "",
"optional": false
},
{
"id": "zitrone",
"name": "Bio-Zitrone",
"amount": 0.5,
"unit": "Stück",
"section": "Zum Abschmecken",
"note": "",
"optional": true
},
{
"id": "salz",
"name": "Salz",
"amount": null,
"unit": "",
"section": "Zum Abschmecken",
"note": "nach Geschmack",
"optional": false
}
],
"steps": [
{
"id": "vorbereiten",
"title": "Ein bisschen Vorbereitung",
"text": "{{basilikum}} waschen und trocken schütteln. {{kaese}} fein reiben. Einen großen Topf Salzwasser aufsetzen.",
"ingredients": [
"basilikum",
"kaese",
"salz"
],
"timers": []
},
{
"id": "pesto",
"title": "Das Pesto zum Leuchten bringen",
"text": "{{kerne}} in einer trockenen Pfanne goldbraun rösten. Mit Basilikum und {{oel}} fein mixen, anschließend den geriebenen Käse unterheben.",
"ingredients": [
"kerne",
"basilikum",
"oel",
"kaese"
],
"timers": [
{
"id": "roesten",
"label": "Pinienkerne rösten",
"seconds": 180
}
]
},
{
"id": "kochen",
"title": "Pasta trifft Pesto",
"text": "{{pasta}} nach Packungsangabe bissfest kochen. Vor dem Abgießen etwas Kochwasser auffangen. Pasta mit dem Pesto vermengen und mit Kochwasser geschmeidig rühren.",
"ingredients": [
"pasta"
],
"timers": [
{
"id": "nudeln",
"label": "Pasta kochen",
"seconds": 600
}
]
},
{
"id": "geniessen",
"title": "Der schönste Moment",
"text": "Mit dem Saft von {{zitrone}} und Salz abschmecken. Auf warme Teller verteilen und sofort genießen.",
"ingredients": [
"zitrone",
"salz"
],
"timers": []
}
],
"tips": "Pesto nicht stark erhitzen: So bleibt es leuchtend grün. Ein Schuss stärkehaltiges Nudelwasser macht die Sauce wunderbar cremig.",
"storage": "Pesto in einem sauberen Glas mit einer dünnen Ölschicht bedecken und im Kühlschrank innerhalb von zwei Tagen verbrauchen.",
"nutrition": {
"kcal": 780,
"protein": 22,
"carbs": 79,
"fat": 42
},
"published": true
},
{
"id": "10000000-0000-4000-8000-000000000002",
"title": "Bunte Garten-Bowl",
"subtitle": "Knackig, frisch und voller guter Dinge.",
"description": "Eine großzügige Schüssel Gemüse, cremige Kichererbsen und ein zitroniges Tahini-Dressing. Genau richtig für einen frischen Start.",
"image": "/images/garden.jpg",
"images": [],
"categories": [
"Vegetarisch"
],
"tags": [
"Meal Prep"
],
"cuisine": "Mediterran",
"difficulty": "Einfach",
"prepMinutes": 20,
"cookMinutes": 10,
"restMinutes": 0,
"servings": 2,
"ingredients": [
{
"id": "kichererbsen",
"name": "Kichererbsen, gekocht",
"amount": 240,
"unit": "g",
"section": "",
"note": "",
"optional": false
},
{
"id": "salat",
"name": "Blattsalat",
"amount": 100,
"unit": "g",
"section": "",
"note": "",
"optional": false
},
{
"id": "tomaten",
"name": "Cherrytomaten",
"amount": 200,
"unit": "g",
"section": "",
"note": "",
"optional": false
},
{
"id": "gurke",
"name": "Gurke",
"amount": 0.5,
"unit": "Stück",
"section": "",
"note": "",
"optional": false
},
{
"id": "tahini",
"name": "Tahini",
"amount": 2,
"unit": "EL",
"section": "",
"note": "",
"optional": false
},
{
"id": "zitrone",
"name": "Zitronensaft",
"amount": 2,
"unit": "EL",
"section": "",
"note": "",
"optional": false
},
{
"id": "wasser",
"name": "Wasser",
"amount": 3,
"unit": "EL",
"section": "",
"note": "",
"optional": false
},
{
"id": "salz",
"name": "Salz",
"amount": null,
"unit": "",
"section": "",
"note": "nach Geschmack",
"optional": false
}
],
"steps": [
{
"id": "gemuese",
"title": "Bunt vorbereiten",
"text": "{{salat}} waschen und trocknen. {{tomaten}} halbieren und {{gurke}} in Scheiben schneiden.",
"ingredients": [
"salat",
"tomaten",
"gurke"
],
"timers": []
},
{
"id": "roesten",
"title": "Kichererbsen rösten",
"text": "{{kichererbsen}} abtropfen lassen und in einer beschichteten Pfanne zehn Minuten rösten. Regelmäßig umrühren.",
"ingredients": [
"kichererbsen"
],
"timers": [
{
"id": "roesten",
"label": "Kichererbsen rösten",
"seconds": 600
}
]
},
{
"id": "dressing",
"title": "Alles kommt zusammen",
"text": "{{tahini}} mit {{zitrone}}, {{wasser}} und Salz verrühren. Gemüse und Kichererbsen anrichten und das Dressing darübergeben.",
"ingredients": [
"tahini",
"zitrone",
"wasser",
"salz"
],
"timers": []
}
],
"tips": "Das Dressing separat aufbewahren, wenn die Bowl für morgen ist.",
"storage": "Gekühlt einen Tag haltbar. Salat und Dressing getrennt lagern.",
"nutrition": null,
"published": true
},
{
"id": "10000000-0000-4000-8000-000000000003",
"title": "Wärmende Gemüsesuppe",
"subtitle": "Ein Löffel Gemütlichkeit.",
"description": "Saisongemüse, weiße Bohnen und aromatische Kräuter machen aus wenigen Zutaten ein wohliges Essen für die ganze Familie.",
"image": "/images/soup.jpg",
"images": [],
"categories": [
"Alltagsküche"
],
"tags": [
"Familienliebling",
"Meal Prep"
],
"cuisine": "",
"difficulty": "Einfach",
"prepMinutes": 15,
"cookMinutes": 30,
"restMinutes": 0,
"servings": 4,
"ingredients": [
{
"id": "karotten",
"name": "Karotten",
"amount": 300,
"unit": "g",
"section": "",
"note": "",
"optional": false
},
{
"id": "kartoffeln",
"name": "Kartoffeln",
"amount": 400,
"unit": "g",
"section": "",
"note": "",
"optional": false
},
{
"id": "bohnen",
"name": "Weiße Bohnen, gekocht",
"amount": 240,
"unit": "g",
"section": "",
"note": "",
"optional": false
},
{
"id": "bruehe",
"name": "Gemüsebrühe",
"amount": 1,
"unit": "l",
"section": "",
"note": "",
"optional": false
},
{
"id": "zwiebel",
"name": "Zwiebel",
"amount": 1,
"unit": "Stück",
"section": "",
"note": "",
"optional": false
},
{
"id": "oel",
"name": "Olivenöl",
"amount": 1,
"unit": "EL",
"section": "",
"note": "",
"optional": false
},
{
"id": "kraeuter",
"name": "Petersilie",
"amount": 0.5,
"unit": "Bund",
"section": "",
"note": "",
"optional": true
}
],
"steps": [
{
"id": "schneiden",
"title": "Alles klein schneiden",
"text": "{{karotten}}, {{kartoffeln}} und {{zwiebel}} schälen und würfeln.",
"ingredients": [
"karotten",
"kartoffeln",
"zwiebel"
],
"timers": []
},
{
"id": "kochen",
"title": "Zeit für Wärme",
"text": "Zwiebel in {{oel}} glasig dünsten. Karotten und Kartoffeln dazugeben, mit {{bruehe}} aufgießen und zugedeckt etwa 25 Minuten sanft kochen, bis das Gemüse weich ist.",
"ingredients": [
"oel",
"bruehe"
],
"timers": [
{
"id": "suppe",
"label": "Gemüse weich kochen",
"seconds": 1500
}
]
},
{
"id": "abschmecken",
"title": "Der letzte Schliff",
"text": "{{bohnen}} dazugeben und fünf Minuten erwärmen. Abschmecken und mit {{kraeuter}} bestreuen.",
"ingredients": [
"bohnen",
"kraeuter"
],
"timers": [
{
"id": "bohnen",
"label": "Bohnen erwärmen",
"seconds": 300
}
]
}
],
"tips": "",
"storage": "Rasch abkühlen und im Kühlschrank maximal zwei Tage lagern. Portionsweise bis zu drei Monate einfrieren.",
"nutrition": null,
"published": true
}
]
}
+167
View File
@@ -0,0 +1,167 @@
{
"version": 1,
"recipes": [
{
"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.",
"image": "",
"images": [],
"categories": [
"Für Gäste"
],
"tags": [
"Grillen",
"Familienliebling"
],
"cuisine": "Serbien",
"difficulty": "Einfach",
"prepMinutes": 20,
"cookMinutes": 12,
"restMinutes": 60,
"servings": 4,
"ingredients": [
{
"id": "fleisch",
"name": "Rinderfaschiertes",
"amount": 800,
"unit": "g",
"section": "Pljeskavica",
"note": "",
"optional": false
},
{
"id": "zwiebel",
"name": "Zwiebel",
"amount": 1,
"unit": "Stück",
"section": "Pljeskavica",
"note": "sehr fein gewürfelt",
"optional": false
},
{
"id": "paprika",
"name": "Paprikapulver edelsüß",
"amount": 2,
"unit": "TL",
"section": "Pljeskavica",
"note": "",
"optional": false
},
{
"id": "salz",
"name": "Salz",
"amount": 10,
"unit": "g",
"section": "Pljeskavica",
"note": "",
"optional": false
},
{
"id": "pfeffer",
"name": "Schwarzer Pfeffer",
"amount": null,
"unit": "",
"section": "Pljeskavica",
"note": "nach Geschmack",
"optional": false
},
{
"id": "wasser",
"name": "Mineralwasser",
"amount": 60,
"unit": "ml",
"section": "Pljeskavica",
"note": "",
"optional": false
},
{
"id": "ajvar",
"name": "Ajvar",
"amount": 120,
"unit": "g",
"section": "Zum Servieren",
"note": "",
"optional": true
},
{
"id": "brot",
"name": "Lepinja",
"amount": 4,
"unit": "Stück",
"section": "Zum Servieren",
"note": "",
"optional": false
}
],
"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"
],
"timers": []
},
{
"id": "ruhen",
"title": "Geschmack entwickeln lassen",
"text": "Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.",
"ingredients": [],
"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.",
"ingredients": [],
"timers": []
},
{
"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.",
"ingredients": [],
"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"
],
"timers": []
}
],
"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.",
"nutrition": null,
"published": false
}
]
}
+167
View File
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<hamham version="1">
<recipes>
<recipe>
<title>Serbische Pljeskavica</title>
<subtitle>Saftig gegrillt, mit Ajvar und frischen Zwiebeln</subtitle>
<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.</description>
<image></image>
<images></images>
<categories>
<category>Für Gäste</category>
</categories>
<tags>
<tag>Grillen</tag>
<tag>Familienliebling</tag>
</tags>
<cuisine>Serbien</cuisine>
<difficulty>Einfach</difficulty>
<prepMinutes>20</prepMinutes>
<cookMinutes>12</cookMinutes>
<restMinutes>60</restMinutes>
<servings>4</servings>
<ingredients>
<ingredient>
<id>fleisch</id>
<name>Rinderfaschiertes</name>
<amount>800</amount>
<unit>g</unit>
<section>Pljeskavica</section>
<note></note>
<optional>false</optional>
</ingredient>
<ingredient>
<id>zwiebel</id>
<name>Zwiebel</name>
<amount>1</amount>
<unit>Stück</unit>
<section>Pljeskavica</section>
<note>sehr fein gewürfelt</note>
<optional>false</optional>
</ingredient>
<ingredient>
<id>paprika</id>
<name>Paprikapulver edelsüß</name>
<amount>2</amount>
<unit>TL</unit>
<section>Pljeskavica</section>
<note></note>
<optional>false</optional>
</ingredient>
<ingredient>
<id>salz</id>
<name>Salz</name>
<amount>10</amount>
<unit>g</unit>
<section>Pljeskavica</section>
<note></note>
<optional>false</optional>
</ingredient>
<ingredient>
<id>pfeffer</id>
<name>Schwarzer Pfeffer</name>
<amount></amount>
<unit></unit>
<section>Pljeskavica</section>
<note>nach Geschmack</note>
<optional>false</optional>
</ingredient>
<ingredient>
<id>wasser</id>
<name>Mineralwasser</name>
<amount>60</amount>
<unit>ml</unit>
<section>Pljeskavica</section>
<note></note>
<optional>false</optional>
</ingredient>
<ingredient>
<id>ajvar</id>
<name>Ajvar</name>
<amount>120</amount>
<unit>g</unit>
<section>Zum Servieren</section>
<note></note>
<optional>true</optional>
</ingredient>
<ingredient>
<id>brot</id>
<name>Lepinja</name>
<amount>4</amount>
<unit>Stück</unit>
<section>Zum Servieren</section>
<note></note>
<optional>false</optional>
</ingredient>
</ingredients>
<steps>
<step>
<id>mischen</id>
<title>Die Mischung vorbereiten</title>
<text>{{fleisch}}, {{zwiebel}}, {{paprika}}, {{salz}} und {{wasser}} gründlich verkneten. Mit {{pfeffer}} abschmecken.</text>
<ingredients>
<ref>fleisch</ref>
<ref>zwiebel</ref>
<ref>paprika</ref>
<ref>salz</ref>
<ref>wasser</ref>
<ref>pfeffer</ref>
</ingredients>
<timers></timers>
</step>
<step>
<id>ruhen</id>
<title>Geschmack entwickeln lassen</title>
<text>Die Masse zugedeckt mindestens eine Stunde im Kühlschrank ruhen lassen.</text>
<ingredients></ingredients>
<timers>
<timer>
<id>ruhezeit</id>
<label>Im Kühlschrank ruhen</label>
<seconds>3600</seconds>
</timer>
</timers>
</step>
<step>
<id>formen</id>
<title>Flache Laibchen formen</title>
<text>Mit angefeuchteten Händen je Portion ein etwa 1,5 cm dickes Laibchen formen. In die Mitte eine leichte Mulde drücken.</text>
<ingredients></ingredients>
<timers></timers>
</step>
<step>
<id>grillen</id>
<title>Kräftig grillen</title>
<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.</text>
<ingredients></ingredients>
<timers>
<timer>
<id>erste-seite</id>
<label>Erste Seite</label>
<seconds>360</seconds>
</timer>
<timer>
<id>zweite-seite</id>
<label>Zweite Seite</label>
<seconds>360</seconds>
</timer>
</timers>
</step>
<step>
<id>servieren</id>
<title>Ab an den Tisch</title>
<text>In {{brot}} mit {{ajvar}} servieren. Nach Wunsch frische Zwiebelringe dazu reichen.</text>
<ingredients>
<ref>brot</ref>
<ref>ajvar</ref>
</ingredients>
<timers></timers>
</step>
</steps>
<tips>Nicht während des Bratens flachdrücken – so bleibt die Pljeskavica saftig. Für Schärfe etwas Chiliflocken zur Masse geben.</tips>
<storage>Reste rasch abkühlen, abgedeckt im Kühlschrank aufbewahren und innerhalb von zwei Tagen verbrauchen. Vor dem Essen gründlich erhitzen.</storage>
<nutrition></nutrition>
<published>false</published>
</recipe>
</recipes>
</hamham>
+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.');
+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 });
}
}
+13
View File
@@ -0,0 +1,13 @@
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
const globalDb = globalThis as unknown as { sql?: ReturnType<typeof postgres> };
export const sql =
globalDb.sql ??
postgres(process.env.DATABASE_URL ?? 'postgres://localhost/hamham', {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
if (process.env.NODE_ENV !== 'production') globalDb.sql = sql;
export const db = drizzle(sql, { schema });
+36
View File
@@ -0,0 +1,36 @@
import { pgTable, uuid, text, timestamp, boolean, jsonb, integer } from 'drizzle-orm/pg-core';
import type { Recipe } from '../lib/recipe';
export const recipes = pgTable('recipes', {
id: uuid().primaryKey().defaultRandom(),
title: text().notNull(),
titleKey: text('title_key').notNull().unique(),
published: boolean().notNull().default(false),
data: jsonb().$type<Recipe>().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const admins = pgTable('admins', {
id: uuid().primaryKey().defaultRandom(),
username: text().notNull().unique(),
password: text().notNull(),
});
export const sessions = pgTable('sessions', {
token: text().primaryKey(),
adminId: uuid('admin_id')
.notNull()
.references(() => admins.id, { onDelete: 'cascade' }),
expires: timestamp({ withTimezone: true }).notNull(),
});
export const limits = pgTable('login_limits', {
key: text().primaryKey(),
count: integer().notNull(),
reset: timestamp({ withTimezone: true }).notNull(),
});
export const settings = pgTable('settings', {
key: text().primaryKey(),
value: jsonb().$type<string[] | string>().notNull(),
});
export const media = pgTable('media', {
name: text().primaryKey(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
+100
View File
@@ -0,0 +1,100 @@
import Decimal from 'decimal.js';
import type { Ingredient, Recipe } from './recipe';
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
export function positive(value: Decimal.Value) {
const n = new Decimal(typeof value === 'string' ? value.replace(',', '.') : value);
if (!n.isFinite() || n.lte(0) || n.gt(1000000000))
throw new Error('Bitte eine positive, endliche Menge bis 1 Milliarde eingeben.');
return n;
}
export function portionFactor(original: number, servings: Decimal.Value) {
return positive(positive(servings).div(positive(original))).toString();
}
const dimensions: Record<string, [string, number]> = {
g: ['mass', 1],
kg: ['mass', 1000],
ml: ['volume', 1],
l: ['volume', 1000],
TL: ['volume', 5],
EL: ['volume', 15],
};
export function convert(value: Decimal.Value, from: string, to: string) {
if (from === to) return new Decimal(value);
const a = dimensions[from],
b = dimensions[to];
if (!a || !b || a[0] !== b[0]) throw new Error('Diese Einheiten sind nicht kompatibel.');
return new Decimal(value).mul(a[1]).div(b[1]);
}
export function ingredientFactor(
ingredient: Ingredient,
value: Decimal.Value,
unit = ingredient.unit,
) {
if (ingredient.amount === null) throw new Error('Diese Zutat hat keine skalierbare Menge.');
return positive(
convert(positive(value), unit, ingredient.unit).div(positive(ingredient.amount)),
).toString();
}
export function scaledAmount(ingredient: Ingredient, factor: Decimal.Value) {
return ingredient.amount === null
? null
: new Decimal(ingredient.amount).mul(positive(factor)).toString();
}
export function formatAmount(value: Decimal.Value | null) {
if (value === null) return '';
const n = new Decimal(value);
const fraction = n.mod(1).toNumber();
const symbol = fraction === 0.5 ? '½' : fraction === 0.25 ? '¼' : fraction === 0.75 ? '¾' : '';
if (symbol && n.lt(10)) return `${n.floor().gt(0) ? n.floor().toString() : ''}${symbol}`;
return n.toNumber().toLocaleString('de-AT', { maximumFractionDigits: n.abs().lt(0.01) ? 6 : 2 });
}
export function ingredientLabel(i: Ingredient, factor: Decimal.Value) {
const value = scaledAmount(i, factor);
return [formatAmount(value), value === null ? '' : i.unit, i.name, i.note]
.filter(Boolean)
.join(' ');
}
export function renderStep(text: string, recipe: Recipe, factor: Decimal.Value) {
return text.replace(/\{\{([^}]+)\}\}/g, (_, id: string) => {
const i = recipe.ingredients.find((i) => i.id === id);
return i ? ingredientLabel(i, factor) : id;
});
}
export type ShoppingItem = {
id: string;
name: string;
amount: string | null;
unit: string;
checked: boolean;
note: string;
};
export function mergeShopping(existing: ShoppingItem[], ingredients: Ingredient[], factor: string) {
const items = existing.map((i) => ({ ...i }));
for (const i of ingredients) {
const amount = scaledAmount(i, factor);
const dimension = dimensions[i.unit];
const unit = dimension ? (dimension[0] === 'mass' ? 'g' : 'ml') : i.unit;
const normalized = amount === null ? null : convert(amount, i.unit, unit);
const same = items.find(
(x) =>
x.name.toLocaleLowerCase('de') === i.name.toLocaleLowerCase('de') &&
x.unit === unit &&
x.note === i.note &&
(x.amount === null) === (amount === null),
);
if (same) {
if (normalized && same.amount)
same.amount = new Decimal(same.amount).plus(normalized).toString();
same.checked = false;
} else
items.push({
id: crypto.randomUUID(),
name: i.name,
amount: normalized?.toString() ?? null,
unit,
checked: false,
note: i.note,
});
}
return items;
}
+21
View File
@@ -0,0 +1,21 @@
export type TimerState = { remaining: number; endAt: number | null; notified?: boolean };
export function remainingMs(timer: TimerState, now = Date.now()) {
return timer.endAt === null
? timer.remaining
: Math.max(0, Math.min(timer.remaining, timer.endAt - now));
}
export function startTimer(timer: TimerState, now = Date.now()): TimerState {
return { remaining: timer.remaining, endAt: now + timer.remaining, notified: false };
}
export function pauseTimer(timer: TimerState, now = Date.now()): TimerState {
return { remaining: remainingMs(timer, now), endAt: null, notified: timer.notified };
}
export type CookingProgress = {
revision: string;
step: number;
factor: string;
checked: string[];
timers: Record<string, TimerState>;
startedAt: number;
finishedAt: number | null;
};
+93
View File
@@ -0,0 +1,93 @@
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import { collectionSchema, type Recipe } from './recipe';
export function parseImport(raw: string, format: 'json' | 'xml') {
if (Buffer.byteLength(raw, 'utf8') > 2 * 1024 * 1024)
throw new Error('Importdatei ist größer als 2 MB.');
let value: unknown;
if (format === 'json') value = JSON.parse(raw);
else {
if (/<!DOCTYPE|<!ENTITY/i.test(raw))
throw new Error('DOCTYPE und Entities sind nicht erlaubt.');
if (XMLValidator.validate(raw) !== true) throw new Error('XML ist nicht wohlgeformt.');
const parser = new XMLParser({
ignoreAttributes: false,
parseTagValue: false,
processEntities: false,
isArray: (_name, path) =>
/\.(recipe|ingredient|step|timer|category|tag|ref)$|\.images\.image$/.test(String(path)),
});
const root = parser.parse(raw).hamham;
if (!root) throw new Error('XML benötigt das Wurzelelement hamham.');
const num = (x: unknown, fallback = 0) => (x === undefined || x === '' ? fallback : Number(x));
const bool = (x: unknown) => x === 'true';
const decode = (s: string) =>
s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
const decodeTree = (v: unknown): unknown =>
typeof v === 'string'
? decode(v)
: Array.isArray(v)
? v.map(decodeTree)
: v && typeof v === 'object'
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, decodeTree(x)]))
: v;
const recipes = (root.recipes?.recipe ?? []).map((r: Record<string, any>) => ({
...r,
servings: num(r.servings),
prepMinutes: num(r.prepMinutes),
cookMinutes: num(r.cookMinutes),
restMinutes: num(r.restMinutes),
published: bool(r.published),
categories: r.categories?.category ?? [],
tags: r.tags?.tag ?? [],
images: r.images?.image ?? [],
ingredients: (r.ingredients?.ingredient ?? []).map((i: Record<string, any>) => ({
...i,
amount: i.amount === '' || i.amount === undefined ? null : Number(i.amount),
optional: bool(i.optional),
})),
steps: (r.steps?.step ?? []).map((s: Record<string, any>) => ({
...s,
ingredients: s.ingredients?.ref ?? [],
timers: (s.timers?.timer ?? []).map((t: Record<string, any>) => ({
...t,
seconds: num(t.seconds),
})),
})),
nutrition:
r.nutrition && typeof r.nutrition === 'object'
? Object.fromEntries(Object.entries(r.nutrition).map(([k, v]) => [k, num(v)]))
: null,
}));
value = { version: Number(root['@_version']), recipes: decodeTree(recipes) };
}
return collectionSchema.parse(value);
}
export function exportRecipes(recipes: Recipe[], format: 'json' | 'xml') {
if (format === 'json') return JSON.stringify({ version: 1, recipes }, null, 2);
const data = recipes.map((r) => ({
...r,
categories: { category: r.categories },
tags: { tag: r.tags },
images: { image: r.images },
ingredients: { ingredient: r.ingredients.map((i) => ({ ...i, amount: i.amount ?? '' })) },
steps: {
step: r.steps.map((s) => ({
...s,
ingredients: { ref: s.ingredients },
timers: { timer: s.timers },
})),
},
nutrition: r.nutrition ?? '',
}));
return (
'<?xml version="1.0" encoding="UTF-8"?>\n' +
new XMLBuilder({ ignoreAttributes: false, format: true }).build({
hamham: { '@_version': 1, recipes: { recipe: data } },
})
);
}
+137
View File
@@ -0,0 +1,137 @@
import { z } from 'zod';
const text = (max: number) =>
z
.string()
.trim()
.max(max)
.refine((v) => !/<\/?[a-z][^>]*>/i.test(v), 'HTML ist nicht erlaubt.');
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
export const units = ['g', 'kg', 'ml', 'l', 'Stück', 'TL', 'EL', 'Prise', 'Bund', ''] as const;
export const ingredientSchema = z.object({
id,
name: text(160).min(1, 'Zutat benötigt einen Namen.'),
amount: z.number().positive().max(1000000).nullable().default(null),
unit: z.enum(units).default(''),
section: text(80).default(''),
note: text(200).default(''),
optional: z.boolean().default(false),
});
export const stepSchema = z.object({
id,
title: text(160).default(''),
text: text(6000).min(1, 'Schritt benötigt eine Anleitung.'),
ingredients: z.array(id).max(100).default([]),
timers: z
.array(z.object({ id, label: text(100).min(1), seconds: z.number().int().min(1).max(172800) }))
.max(10)
.default([]),
});
const image = z
.string()
.regex(
/^\/(?:media\/[a-f0-9-]+\.webp|images\/[a-z0-9-]+\.(?:webp|jpg|svg))$/,
'Nur lokale Bilder sind erlaubt.',
);
export const recipeSchema = z
.object({
id: z.string().uuid().optional(),
title: text(180).min(2, 'Titel: mindestens 2 Zeichen.'),
subtitle: text(200).default(''),
description: text(4000).default(''),
image: image.or(z.literal('')).default(''),
images: z.array(image).max(20).default([]),
categories: z.array(text(60).min(1)).max(20).default([]),
tags: z.array(text(60).min(1)).max(30).default([]),
cuisine: text(80).default(''),
difficulty: z.enum(['Einfach', 'Mittel', 'Anspruchsvoll']).default('Einfach'),
prepMinutes: z.number().int().min(0).max(10080).default(0),
cookMinutes: z.number().int().min(0).max(10080).default(0),
restMinutes: z.number().int().min(0).max(10080).default(0),
servings: z.number().positive().max(10000),
ingredients: z.array(ingredientSchema).min(1).max(200),
steps: z.array(stepSchema).min(1).max(100),
tips: text(6000).default(''),
storage: text(3000).default(''),
nutrition: z
.object({
kcal: z.number().min(0),
protein: z.number().min(0),
carbs: z.number().min(0),
fat: z.number().min(0),
})
.nullable()
.default(null),
published: z.boolean().default(false),
createdAt: z.iso.datetime().optional(),
updatedAt: z.iso.datetime().optional(),
})
.superRefine((r, ctx) => {
const ids = new Set(r.ingredients.map((i) => i.id));
if (ids.size !== r.ingredients.length)
ctx.addIssue({
code: 'custom',
path: ['ingredients'],
message: 'Zutaten-IDs müssen eindeutig sein.',
});
const stepIds = new Set(r.steps.map((s) => s.id));
if (stepIds.size !== r.steps.length)
ctx.addIssue({
code: 'custom',
path: ['steps'],
message: 'Schritt-IDs müssen eindeutig sein.',
});
const timers = r.steps.flatMap((s) => s.timers.map((t) => t.id));
if (new Set(timers).size !== timers.length)
ctx.addIssue({
code: 'custom',
path: ['steps'],
message: 'Timer-IDs müssen eindeutig sein.',
});
r.steps.forEach((s, n) => {
const refs = [
...s.ingredients,
...Array.from(s.text.matchAll(/\{\{([^}]+)\}\}/g), (m) => m[1]),
];
if (refs.some((ref) => !ids.has(ref)))
ctx.addIssue({
code: 'custom',
path: ['steps', n],
message: 'Unbekannte Zutatenreferenz.',
});
});
});
export const collectionSchema = z.object({
version: z.literal(1),
recipes: z.array(recipeSchema).min(1).max(100),
});
export type Ingredient = z.infer<typeof ingredientSchema>;
export type Step = z.infer<typeof stepSchema>;
export type Recipe = z.infer<typeof recipeSchema>;
export type SavedRecipe = Recipe & { id: string; createdAt: string; updatedAt: string };
export const totalMinutes = (r: Recipe) => r.prepMinutes + r.cookMinutes + r.restMinutes;
export function emptyRecipe(): Recipe {
return {
title: '',
subtitle: '',
description: '',
image: '',
images: [],
categories: [],
tags: [],
cuisine: '',
difficulty: 'Einfach',
prepMinutes: 10,
cookMinutes: 20,
restMinutes: 0,
servings: 2,
ingredients: [
{ id: 'i1', name: '', amount: null, unit: 'g', section: '', note: '', optional: false },
],
steps: [{ id: 's1', title: '', text: '', ingredients: [], timers: [] }],
tips: '',
storage: '',
nutrition: null,
published: false,
};
}
+58
View File
@@ -0,0 +1,58 @@
import { eq, desc, and } from 'drizzle-orm';
import { db } from '@/db';
import { recipes } from '@/db/schema';
import type { Recipe, SavedRecipe } from './recipe';
import { ApiError } from './server';
export function serialize(row: typeof recipes.$inferSelect): SavedRecipe {
return {
...row.data,
id: row.id,
title: row.title,
published: row.published,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function listRecipes(isAdmin = false) {
return (
await db
.select()
.from(recipes)
.where(isAdmin ? undefined : eq(recipes.published, true))
.orderBy(desc(recipes.createdAt))
).map(serialize);
}
export async function getRecipe(id: string, isAdmin = false) {
if (!/^[0-9a-f-]{36}$/i.test(id)) return null;
const [row] = await db
.select()
.from(recipes)
.where(and(eq(recipes.id, id), isAdmin ? undefined : eq(recipes.published, true)));
return row ? serialize(row) : null;
}
export const titleKey = (title: string) => title.normalize('NFKC').trim().toLocaleLowerCase('de');
export async function saveRecipe(data: Recipe, id?: string, expected?: string) {
const values = {
title: data.title,
titleKey: titleKey(data.title),
published: data.published,
data,
updatedAt: new Date(),
};
if (id) {
if (!expected) throw new ApiError(428, 'Versionsstand zum Speichern fehlt.');
const [row] = await db
.update(recipes)
.set(values)
.where(and(eq(recipes.id, id), eq(recipes.updatedAt, new Date(expected))))
.returning();
if (!row)
throw new ApiError(
409,
'Das Rezept wurde zwischenzeitlich verändert. Entwurf sichern und Seite neu laden.',
);
return serialize(row);
}
const [row] = await db.insert(recipes).values(values).returning();
return serialize(row);
}
+96
View File
@@ -0,0 +1,96 @@
import { cookies } from 'next/headers';
import { createHash, randomBytes } from 'node:crypto';
import { eq, and, gt } from 'drizzle-orm';
import { db, sql } from '@/db';
import { admins, sessions } from '@/db/schema';
import { ZodError } from 'zod';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export const digest = (s: string) => createHash('sha256').update(s).digest('hex');
export async function admin() {
const token = (await cookies()).get('hamham_session')?.value;
if (!token) return null;
const [row] = await db
.select({ id: admins.id, username: admins.username })
.from(sessions)
.innerJoin(admins, eq(sessions.adminId, admins.id))
.where(and(eq(sessions.token, digest(token)), gt(sessions.expires, new Date())));
return row ?? null;
}
export async function requireAdmin() {
const user = await admin();
if (!user) throw new ApiError(401, 'Bitte als Administrator anmelden.');
return user;
}
export function checkOrigin(req: Request) {
const expected = process.env.APP_ORIGIN;
if (!expected || req.headers.get('origin') !== new URL(expected).origin)
throw new ApiError(403, 'Anfrage von unbekannter Herkunft abgelehnt.');
}
export async function bodyText(req: Request, max = 2 * 1024 * 1024) {
if (Number(req.headers.get('content-length') ?? 0) > max)
throw new ApiError(413, 'Datei oder Anfrage ist zu groß.');
const reader = req.body?.getReader();
if (!reader) return '';
let size = 0;
const parts: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > max) {
await reader.cancel();
throw new ApiError(413, 'Datei oder Anfrage ist zu groß.');
}
parts.push(value);
}
return Buffer.concat(parts).toString('utf8');
}
export async function jsonBody(req: Request) {
try {
return JSON.parse(await bodyText(req));
} catch (e) {
if (e instanceof ApiError) throw e;
throw new ApiError(400, 'Ungültige JSON-Anfrage.');
}
}
export async function newSession(adminId: string) {
const token = randomBytes(32).toString('hex');
await db
.insert(sessions)
.values({ token: digest(token), adminId, expires: new Date(Date.now() + 7 * 86400000) });
(await cookies()).set('hamham_session', token, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'strict',
path: '/',
maxAge: 7 * 86400,
});
}
export async function rateLimit(key: string, max: number) {
const [row] =
await sql`INSERT INTO login_limits (key, count, reset) VALUES (${key}, 1, now() + interval '15 minutes') ON CONFLICT (key) DO UPDATE SET count = CASE WHEN login_limits.reset < now() THEN 1 ELSE login_limits.count + 1 END, reset = CASE WHEN login_limits.reset < now() THEN now() + interval '15 minutes' ELSE login_limits.reset END RETURNING count`;
if (row.count > max)
throw new ApiError(429, 'Zu viele Versuche. Bitte in 15 Minuten erneut versuchen.');
}
export function apiError(e: unknown) {
if (e instanceof ApiError) return Response.json({ error: e.message }, { status: e.status });
if (e instanceof ZodError)
return Response.json(
{ error: e.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n') },
{ status: 422 },
);
if ((e as { code?: string })?.code === '23505')
return Response.json(
{ error: 'Ein Rezept oder Eintrag mit diesem Namen existiert bereits.' },
{ status: 409 },
);
console.error('API error', e);
return Response.json({ error: 'Die Anfrage konnte nicht verarbeitet werden.' }, { status: 500 });
}
+173
View File
@@ -0,0 +1,173 @@
import { recipeSchema } from '../src/lib/recipe';
export const pasta = recipeSchema.parse({
id: '10000000-0000-4000-8000-000000000001',
title: 'Grüne Pesto-Pasta',
subtitle: 'Ein Teller Sommer, ganz ohne Eile.',
description:
'Frisches Basilikum, geröstete Pinienkerne und ein Hauch Zitrone. Unsere liebste Pasta für Tage, an denen es einfach gut sein soll.',
image: '/images/kitchen.jpg',
categories: ['Alltagsküche', 'Vegetarisch'],
tags: ['Schnell', 'Familienliebling'],
cuisine: 'Italien',
servings: 2,
prepMinutes: 10,
cookMinutes: 12,
published: true,
ingredients: [
{ id: 'pasta', name: 'Pasta', amount: 200, unit: 'g', section: 'Für die Pasta' },
{ id: 'basilikum', name: 'Basilikum', amount: 40, unit: 'g', section: 'Für das Pesto' },
{ id: 'kerne', name: 'Pinienkerne', amount: 30, unit: 'g', section: 'Für das Pesto' },
{ id: 'oel', name: 'Olivenöl', amount: 60, unit: 'ml', section: 'Für das Pesto' },
{
id: 'kaese',
name: 'Hartkäse mit mikrobiellem Lab',
amount: 40,
unit: 'g',
section: 'Für das Pesto',
},
{
id: 'zitrone',
name: 'Bio-Zitrone',
amount: 0.5,
unit: 'Stück',
section: 'Zum Abschmecken',
optional: true,
},
{
id: 'salz',
name: 'Salz',
amount: null,
unit: '',
note: 'nach Geschmack',
section: 'Zum Abschmecken',
},
],
steps: [
{
id: 'vorbereiten',
title: 'Ein bisschen Vorbereitung',
text: '{{basilikum}} waschen und trocken schütteln. {{kaese}} fein reiben. Einen großen Topf Salzwasser aufsetzen.',
ingredients: ['basilikum', 'kaese', 'salz'],
},
{
id: 'pesto',
title: 'Das Pesto zum Leuchten bringen',
text: '{{kerne}} in einer trockenen Pfanne goldbraun rösten. Mit Basilikum und {{oel}} fein mixen, anschließend den geriebenen Käse unterheben.',
ingredients: ['kerne', 'basilikum', 'oel', 'kaese'],
timers: [{ id: 'roesten', label: 'Pinienkerne rösten', seconds: 180 }],
},
{
id: 'kochen',
title: 'Pasta trifft Pesto',
text: '{{pasta}} nach Packungsangabe bissfest kochen. Vor dem Abgießen etwas Kochwasser auffangen. Pasta mit dem Pesto vermengen und mit Kochwasser geschmeidig rühren.',
ingredients: ['pasta'],
timers: [{ id: 'nudeln', label: 'Pasta kochen', seconds: 600 }],
},
{
id: 'geniessen',
title: 'Der schönste Moment',
text: 'Mit dem Saft von {{zitrone}} und Salz abschmecken. Auf warme Teller verteilen und sofort genießen.',
ingredients: ['zitrone', 'salz'],
},
],
tips: 'Pesto nicht stark erhitzen: So bleibt es leuchtend grün. Ein Schuss stärkehaltiges Nudelwasser macht die Sauce wunderbar cremig.',
storage:
'Pesto in einem sauberen Glas mit einer dünnen Ölschicht bedecken und im Kühlschrank innerhalb von zwei Tagen verbrauchen.',
nutrition: { kcal: 780, protein: 22, carbs: 79, fat: 42 },
});
export const salad = recipeSchema.parse({
id: '10000000-0000-4000-8000-000000000002',
title: 'Bunte Garten-Bowl',
subtitle: 'Knackig, frisch und voller guter Dinge.',
description:
'Eine großzügige Schüssel Gemüse, cremige Kichererbsen und ein zitroniges Tahini-Dressing. Genau richtig für einen frischen Start.',
image: '/images/garden.jpg',
categories: ['Vegetarisch'],
tags: ['Meal Prep'],
cuisine: 'Mediterran',
servings: 2,
prepMinutes: 20,
cookMinutes: 10,
published: true,
ingredients: [
{ id: 'kichererbsen', name: 'Kichererbsen, gekocht', amount: 240, unit: 'g' },
{ id: 'salat', name: 'Blattsalat', amount: 100, unit: 'g' },
{ id: 'tomaten', name: 'Cherrytomaten', amount: 200, unit: 'g' },
{ id: 'gurke', name: 'Gurke', amount: 0.5, unit: 'Stück' },
{ id: 'tahini', name: 'Tahini', amount: 2, unit: 'EL' },
{ id: 'zitrone', name: 'Zitronensaft', amount: 2, unit: 'EL' },
{ id: 'wasser', name: 'Wasser', amount: 3, unit: 'EL' },
{ id: 'salz', name: 'Salz', amount: null, note: 'nach Geschmack' },
],
steps: [
{
id: 'gemuese',
title: 'Bunt vorbereiten',
text: '{{salat}} waschen und trocknen. {{tomaten}} halbieren und {{gurke}} in Scheiben schneiden.',
ingredients: ['salat', 'tomaten', 'gurke'],
},
{
id: 'roesten',
title: 'Kichererbsen rösten',
text: '{{kichererbsen}} abtropfen lassen und in einer beschichteten Pfanne zehn Minuten rösten. Regelmäßig umrühren.',
ingredients: ['kichererbsen'],
timers: [{ id: 'roesten', label: 'Kichererbsen rösten', seconds: 600 }],
},
{
id: 'dressing',
title: 'Alles kommt zusammen',
text: '{{tahini}} mit {{zitrone}}, {{wasser}} und Salz verrühren. Gemüse und Kichererbsen anrichten und das Dressing darübergeben.',
ingredients: ['tahini', 'zitrone', 'wasser', 'salz'],
},
],
tips: 'Das Dressing separat aufbewahren, wenn die Bowl für morgen ist.',
storage: 'Gekühlt einen Tag haltbar. Salat und Dressing getrennt lagern.',
});
export const soup = recipeSchema.parse({
id: '10000000-0000-4000-8000-000000000003',
title: 'Wärmende Gemüsesuppe',
subtitle: 'Ein Löffel Gemütlichkeit.',
description:
'Saisongemüse, weiße Bohnen und aromatische Kräuter machen aus wenigen Zutaten ein wohliges Essen für die ganze Familie.',
image: '/images/soup.jpg',
categories: ['Alltagsküche'],
tags: ['Familienliebling', 'Meal Prep'],
servings: 4,
prepMinutes: 15,
cookMinutes: 30,
published: true,
ingredients: [
{ id: 'karotten', name: 'Karotten', amount: 300, unit: 'g' },
{ id: 'kartoffeln', name: 'Kartoffeln', amount: 400, unit: 'g' },
{ id: 'bohnen', name: 'Weiße Bohnen, gekocht', amount: 240, unit: 'g' },
{ id: 'bruehe', name: 'Gemüsebrühe', amount: 1, unit: 'l' },
{ id: 'zwiebel', name: 'Zwiebel', amount: 1, unit: 'Stück' },
{ id: 'oel', name: 'Olivenöl', amount: 1, unit: 'EL' },
{ id: 'kraeuter', name: 'Petersilie', amount: 0.5, unit: 'Bund', optional: true },
],
steps: [
{
id: 'schneiden',
title: 'Alles klein schneiden',
text: '{{karotten}}, {{kartoffeln}} und {{zwiebel}} schälen und würfeln.',
ingredients: ['karotten', 'kartoffeln', 'zwiebel'],
},
{
id: 'kochen',
title: 'Zeit für Wärme',
text: 'Zwiebel in {{oel}} glasig dünsten. Karotten und Kartoffeln dazugeben, mit {{bruehe}} aufgießen und zugedeckt etwa 25 Minuten sanft kochen, bis das Gemüse weich ist.',
ingredients: ['oel', 'bruehe'],
timers: [{ id: 'suppe', label: 'Gemüse weich kochen', seconds: 1500 }],
},
{
id: 'abschmecken',
title: 'Der letzte Schliff',
text: '{{bohnen}} dazugeben und fünf Minuten erwärmen. Abschmecken und mit {{kraeuter}} bestreuen.',
ingredients: ['bohnen', 'kraeuter'],
timers: [{ id: 'bohnen', label: 'Bohnen erwärmen', seconds: 300 }],
},
],
storage:
'Rasch abkühlen und im Kühlschrank maximal zwei Tage lagern. Portionsweise bis zu drei Monate einfrieren.',
});
export const examples = [pasta, salad, soup];
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { ingredientSchema } from '../../src/lib/recipe';
import {
positive,
portionFactor,
ingredientFactor,
scaledAmount,
formatAmount,
convert,
renderStep,
mergeShopping,
} from '../../src/lib/calculator';
import { pasta } from '../fixtures';
const chicken = ingredientSchema.parse({
id: 'chicken',
name: 'Hühnerbrust',
amount: 400,
unit: 'g',
});
describe('Precise scaling from immutable originals', () => {
it('scales 2 to 8 portions', () => {
const f = portionFactor(2, 8);
expect(f).toBe('4');
expect(scaledAmount(chicken, f)).toBe('1600');
});
it('scales all ingredients from 400 g to 800 g', () => {
const f = ingredientFactor(chicken, 800);
expect(f).toBe('2');
expect(scaledAmount({ ...chicken, amount: 200 }, f)).toBe('400');
expect(scaledAmount({ ...chicken, amount: 50 }, f)).toBe('100');
});
it('supports downscaling and comma decimals', () => {
expect(ingredientFactor(chicken, '0,2', 'kg')).toBe('0.5');
expect(portionFactor(2, '1,5')).toBe('0.75');
});
it('normalizes kg, liters and spoons without conflating mass with volume', () => {
expect(convert(1.5, 'kg', 'g').toString()).toBe('1500');
expect(convert(250, 'ml', 'l').toString()).toBe('0.25');
expect(convert(2, 'EL', 'TL').toString()).toBe('6');
expect(() => convert(2, 'g', 'ml')).toThrow();
});
it('scales optional ingredients and leaves unquantified ingredients unchanged', () => {
expect(scaledAmount({ ...chicken, optional: true }, 2)).toBe('800');
expect(scaledAmount({ ...chicken, amount: null }, 2)).toBeNull();
expect(() => ingredientFactor({ ...chicken, amount: null }, 3)).toThrow();
});
it.each([0, -1, 'NaN', 'Infinity', '', 'abc', 1000000001])('rejects invalid amounts: %s', (v) => {
expect(() => positive(v)).toThrow();
});
it('prevents division by zero', () => {
expect(() => ingredientFactor({ ...chicken, amount: 0 }, 1)).toThrow();
expect(() => portionFactor(0, 4)).toThrow();
});
it('rejects extreme scale factors before they reach the interface', () => {
expect(() => ingredientFactor({ ...chicken, amount: 0.000001 }, 1000000)).toThrow();
expect(() => portionFactor(0.000001, 1000000)).toThrow();
});
it('keeps exact quantities despite repeated rescaling', () => {
for (let n = 1; n <= 100; n++) {
expect(scaledAmount(chicken, ingredientFactor(chicken, n))).toBe(String(n));
}
expect(scaledAmount(chicken, '1')).toBe('400');
expect(scaledAmount({ ...chicken, amount: 0.1 }, 3)).toBe('0.3');
});
it('uses readable fractions and final display rounding', () => {
expect(formatAmount(0.5)).toBe('½');
expect(formatAmount(1.25)).toBe('1¼');
expect(formatAmount(2.75)).toBe('2¾');
expect(formatAmount(10.6666)).toBe('10,67');
expect(formatAmount(null)).toBe('');
});
it('updates only structured references in instructions', () => {
expect(renderStep('{{pasta}} für 10 Minuten kochen.', pasta, '2')).toBe(
'400 g Pasta für 10 Minuten kochen.',
);
});
it('merges compatible shopping quantities across recipes', () => {
const list = mergeShopping([], [chicken], '2');
const result = mergeShopping(list, [{ ...chicken, amount: 0.5, unit: 'kg' }], '1');
expect(result).toHaveLength(1);
expect(result[0].amount).toBe('1300');
expect(list[0].amount).toBe('800');
});
it('keeps incompatible shopping units and notes separate', () => {
const a = mergeShopping([], [chicken], '1');
const b = mergeShopping(
a,
[
{ ...chicken, unit: 'Stück' },
{ ...chicken, note: 'gekocht' },
],
'1',
);
expect(b).toHaveLength(3);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { startTimer, pauseTimer, remainingMs } from '../../src/lib/cooking';
describe('Timestamp-based kitchen timers', () => {
it('does not display an extra second when the last render predates the start', () => {
expect(remainingMs(startTimer({ remaining: 60000, endAt: null }, 1200), 1000)).toBe(60000);
});
it('starts with an absolute deadline, not ticking state', () => {
expect(startTimer({ remaining: 60000, endAt: null }, 1000).endAt).toBe(61000);
});
it('survives navigation, sleep and serialized progress', () => {
const timer = JSON.parse(JSON.stringify(startTimer({ remaining: 60000, endAt: null }, 1000)));
expect(remainingMs(timer, 41000)).toBe(20000);
expect(remainingMs(timer, 90000)).toBe(0);
});
it('pauses precisely and resumes without drift', () => {
const paused = pauseTimer(startTimer({ remaining: 60000, endAt: null }, 1000), 21000);
expect(remainingMs(paused, 90000)).toBe(40000);
expect(remainingMs(startTimer(paused, 90000), 100000)).toBe(30000);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { parseImport, exportRecipes } from '../../src/lib/import';
import { recipeSchema } from '../../src/lib/recipe';
import { examples, pasta } from '../fixtures';
describe('Versioned, safe, lossless recipe interchange', () => {
it.each(['json', 'xml'] as const)('roundtrips all fields in %s', (format) => {
expect(parseImport(exportRecipes(examples, format), format)).toEqual({
version: 1,
recipes: examples,
});
});
it('preserves XML escaped punctuation, unicode and literal ampersands', () => {
const r = {
...pasta,
title: 'Öl & Gemüse',
tips: 'Mit "Liebe" & Geduld. 1 < 2. &amp; bleibt Text.',
};
expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r);
});
it('rejects unknown versions and missing mandatory fields', () => {
expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow();
expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow();
});
it('rejects HTML payloads, invalid image paths and broken references', () => {
expect(() => recipeSchema.parse({ ...pasta, title: '<script>alert(1)</script>' })).toThrow();
expect(() => recipeSchema.parse({ ...pasta, image: '/media/../../etc/passwd' })).toThrow();
expect(() =>
recipeSchema.parse({ ...pasta, steps: [{ ...pasta.steps[0], text: '{{unknown}}' }] }),
).toThrow();
});
it('rejects duplicate ingredient, step and timer IDs', () => {
expect(() =>
recipeSchema.parse({ ...pasta, ingredients: [...pasta.ingredients, pasta.ingredients[0]] }),
).toThrow();
expect(() =>
recipeSchema.parse({ ...pasta, steps: [pasta.steps[1], pasta.steps[1]] }),
).toThrow();
});
it.each([
'<!DOCTYPE x [<!ENTITY a SYSTEM "file:///etc/passwd">]><x>&a;</x>',
'<!DOCTYPE x [<!ENTITY a "&a;&a;">]><x>&a;</x>',
'<hamham><broken></hamham>',
])('rejects unsafe or malformed XML', (raw) => {
expect(() => parseImport(raw, 'xml')).toThrow();
});
it('enforces byte limits before parsing', () => {
expect(() => parseImport('x'.repeat(2 * 1024 * 1024 + 1), 'json')).toThrow(/2 MB/);
});
});
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { include: ['tests/unit/**/*.test.ts'] } });