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
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
uploads/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
package-lock.json
|
||||||
|
public/examples
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
docs/screenshots
|
||||||
|
uploads
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }
|
||||||
@@ -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());
|
||||||
Vendored
+7
@@ -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.
|
||||||
@@ -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;
|
||||||
Generated
+3697
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/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 } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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. & 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({ test: { include: ['tests/unit/**/*.test.ts'] } });
|
||||||
Reference in new issue
Block a user