getMessage(); include __DIR__ . '/maintenance/db-unavailable.php'; exit; } function db_table_has_column(PDO $pdo, string $table, string $column): bool { $stmt = $pdo->prepare( 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?' ); $stmt->execute([$table, $column]); return (bool) $stmt->fetchColumn(); } function apply_recipe_schema(PDO $pdo): void { $path = __DIR__ . '/scripts/schema.sql'; if (!file_exists($path)) { throw new RuntimeException('Missing scripts/schema.sql'); } $pdo->exec(file_get_contents($path)); } function recipe_empty_i18n(): array { return [ 'title' => '', 'description' => '', 'category' => '', 'difficulty' => '', 'tags' => [], 'ingredients' => [], 'utensils' => [], 'steps' => [], 'step_videos' => [], 'step_timers' => [], ]; } function recipe_base_from_row(array $row): array { return [ 'slug' => $row['slug'], 'hero' => $row['hero'] ?? '', 'prep_time' => (int) ($row['prep_time'] ?? 0), 'cook_time' => (int) ($row['cook_time'] ?? 0), 'total_time' => (int) ($row['total_time'] ?? 0), 'servings' => (int) ($row['servings'] ?? 2), 'featured' => (bool) ($row['featured'] ?? false), 'coming_soon' => (bool) ($row['coming_soon'] ?? false), 'nutrition' => [ 'calories' => (int) ($row['calories'] ?? 0), 'protein' => (int) ($row['protein'] ?? 0), 'carbs' => (int) ($row['carbs'] ?? 0), 'fat' => (int) ($row['fat'] ?? 0), ], 'i18n' => [ 'en' => recipe_empty_i18n(), 'de' => recipe_empty_i18n(), ], ]; } function decode_legacy_jsonb_value($value): ?array { if (is_array($value)) { return $value; } if (is_string($value)) { $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : null; } if ($value !== null) { $decoded = json_decode(json_encode($value), true); return is_array($decoded) ? $decoded : null; } return null; } function migrate_legacy_jsonb_storage(PDO $pdo): void { if (!db_table_has_column($pdo, 'recipes', 'data')) { return; } $legacy = []; $stmt = $pdo->query('SELECT slug, data FROM recipes'); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $recipe = decode_legacy_jsonb_value($row['data']); if (is_array($recipe) && !empty($recipe['slug'])) { $legacy[] = $recipe; } } $pdo->exec('DROP TABLE IF EXISTS recipe_steps CASCADE'); $pdo->exec('DROP TABLE IF EXISTS recipe_utensils CASCADE'); $pdo->exec('DROP TABLE IF EXISTS recipe_ingredients CASCADE'); $pdo->exec('DROP TABLE IF EXISTS recipe_tags CASCADE'); $pdo->exec('DROP TABLE IF EXISTS recipe_translations CASCADE'); $pdo->exec('DROP TABLE IF EXISTS recipes CASCADE'); apply_recipe_schema($pdo); foreach ($legacy as $recipe) { save_recipe($recipe, $pdo); } } function ensure_recipe_schema(PDO $pdo): void { static $ready = false; if ($ready) { return; } if (db_table_has_column($pdo, 'recipes', 'data') && !db_table_has_column($pdo, 'recipes', 'calories')) { migrate_legacy_jsonb_storage($pdo); $ready = true; return; } apply_recipe_schema($pdo); $ready = true; } function require_database(): PDO { $pdo = get_db_connection(); if (!$pdo) { $message = getenv('DATABASE_URL') ? 'Database connection failed. Check DATABASE_URL and that Postgres is running.' : 'DATABASE_URL is not set in .env.'; throw new DatabaseUnavailableException($message); } ensure_recipe_schema($pdo); return $pdo; } function init_db(): void { require_database(); } function hydrate_recipes_from_db(PDO $pdo): array { $recipes = []; $stmt = $pdo->query( 'SELECT * FROM recipes ORDER BY featured DESC, updated_at DESC, slug ASC' ); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $recipes[$row['slug']] = recipe_base_from_row($row); } if ($recipes === []) { return []; } $stmt = $pdo->query('SELECT * FROM recipe_translations'); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $slug = $row['recipe_slug']; $lang = $row['lang']; if (!isset($recipes[$slug])) { continue; } $recipes[$slug]['i18n'][$lang] = array_merge(recipe_empty_i18n(), [ 'title' => $row['title'], 'description' => $row['description'], 'category' => $row['category'], 'difficulty' => $row['difficulty'], ]); } $stmt = $pdo->query('SELECT recipe_slug, lang, tag FROM recipe_tags ORDER BY sort_order, id'); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) { $recipes[$row['recipe_slug']]['i18n'][$row['lang']]['tags'][] = $row['tag']; } } $stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_ingredients ORDER BY sort_order, id'); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) { $recipes[$row['recipe_slug']]['i18n'][$row['lang']]['ingredients'][] = $row['content']; } } $stmt = $pdo->query('SELECT recipe_slug, lang, content FROM recipe_utensils ORDER BY sort_order, id'); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if (isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) { $recipes[$row['recipe_slug']]['i18n'][$row['lang']]['utensils'][] = $row['content']; } } $stmt = $pdo->query( 'SELECT recipe_slug, lang, content, video_url, timer_minutes FROM recipe_steps ORDER BY sort_order, id' ); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if (!isset($recipes[$row['recipe_slug']]['i18n'][$row['lang']])) { continue; } $lang = &$recipes[$row['recipe_slug']]['i18n'][$row['lang']]; $lang['steps'][] = $row['content']; $lang['step_videos'][] = $row['video_url'] ?? ''; $lang['step_timers'][] = $row['timer_minutes'] !== null ? (string) $row['timer_minutes'] : ''; unset($lang); } $list = array_values($recipes); foreach ($list as &$recipe) { if (!empty($recipe['hero']) && is_string($recipe['hero'])) { $recipe['hero'] = normalize_asset_path($recipe['hero']); } } unset($recipe); return $list; } function load_recipes(): array { $pdo = require_database(); return hydrate_recipes_from_db($pdo); } function load_recipe_by_slug(string $slug): ?array { foreach (load_recipes() as $recipe) { if (($recipe['slug'] ?? '') === $slug) { return $recipe; } } return null; } function clear_featured_recipes(?PDO $pdo = null): void { $pdo = $pdo ?? require_database(); $pdo->exec('UPDATE recipes SET featured = FALSE'); } function delete_recipe(string $slug): bool { $pdo = require_database(); $stmt = $pdo->prepare('DELETE FROM recipes WHERE slug = ?'); $stmt->execute([$slug]); return $stmt->rowCount() > 0; } function save_recipe(array $recipe, ?PDO $pdo = null): bool { if (empty($recipe['slug'])) { return false; } $pdo = $pdo ?? require_database(); $nutrition = $recipe['nutrition'] ?? []; $slug = $recipe['slug']; try { $pdo->beginTransaction(); $stmt = $pdo->prepare( 'INSERT INTO recipes ( slug, hero, prep_time, cook_time, total_time, servings, featured, coming_soon, calories, protein, carbs, fat, updated_at ) VALUES ( :slug, :hero, :prep_time, :cook_time, :total_time, :servings, :featured, :coming_soon, :calories, :protein, :carbs, :fat, NOW() ) ON CONFLICT (slug) DO UPDATE SET hero = EXCLUDED.hero, prep_time = EXCLUDED.prep_time, cook_time = EXCLUDED.cook_time, total_time = EXCLUDED.total_time, servings = EXCLUDED.servings, featured = EXCLUDED.featured, coming_soon = EXCLUDED.coming_soon, calories = EXCLUDED.calories, protein = EXCLUDED.protein, carbs = EXCLUDED.carbs, fat = EXCLUDED.fat, updated_at = NOW()' ); $stmt->bindValue(':slug', $slug); $stmt->bindValue(':hero', $recipe['hero'] ?? ''); $stmt->bindValue(':prep_time', (int) ($recipe['prep_time'] ?? 0), PDO::PARAM_INT); $stmt->bindValue(':cook_time', (int) ($recipe['cook_time'] ?? 0), PDO::PARAM_INT); $stmt->bindValue(':total_time', (int) ($recipe['total_time'] ?? 0), PDO::PARAM_INT); $stmt->bindValue(':servings', (int) ($recipe['servings'] ?? 2), PDO::PARAM_INT); $stmt->bindValue(':featured', !empty($recipe['featured']), PDO::PARAM_BOOL); $stmt->bindValue(':coming_soon', !empty($recipe['coming_soon']), PDO::PARAM_BOOL); $stmt->bindValue(':calories', (int) ($nutrition['calories'] ?? 0), PDO::PARAM_INT); $stmt->bindValue(':protein', (int) ($nutrition['protein'] ?? 0), PDO::PARAM_INT); $stmt->bindValue(':carbs', (int) ($nutrition['carbs'] ?? 0), PDO::PARAM_INT); $stmt->bindValue(':fat', (int) ($nutrition['fat'] ?? 0), PDO::PARAM_INT); $stmt->execute(); $pdo->prepare('DELETE FROM recipe_translations WHERE recipe_slug = ?')->execute([$slug]); $pdo->prepare('DELETE FROM recipe_tags WHERE recipe_slug = ?')->execute([$slug]); $pdo->prepare('DELETE FROM recipe_ingredients WHERE recipe_slug = ?')->execute([$slug]); $pdo->prepare('DELETE FROM recipe_utensils WHERE recipe_slug = ?')->execute([$slug]); $pdo->prepare('DELETE FROM recipe_steps WHERE recipe_slug = ?')->execute([$slug]); $translationStmt = $pdo->prepare( 'INSERT INTO recipe_translations (recipe_slug, lang, title, description, category, difficulty) VALUES (?, ?, ?, ?, ?, ?)' ); $tagStmt = $pdo->prepare( 'INSERT INTO recipe_tags (recipe_slug, lang, tag, sort_order) VALUES (?, ?, ?, ?)' ); $ingredientStmt = $pdo->prepare( 'INSERT INTO recipe_ingredients (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)' ); $utensilStmt = $pdo->prepare( 'INSERT INTO recipe_utensils (recipe_slug, lang, content, sort_order) VALUES (?, ?, ?, ?)' ); $stepStmt = $pdo->prepare( 'INSERT INTO recipe_steps (recipe_slug, lang, content, video_url, timer_minutes, sort_order) VALUES (?, ?, ?, ?, ?, ?)' ); foreach (['en', 'de'] as $lang) { $block = $recipe['i18n'][$lang] ?? []; $translationStmt->execute([ $slug, $lang, $block['title'] ?? '', $block['description'] ?? '', $block['category'] ?? '', $block['difficulty'] ?? '', ]); foreach (array_values($block['tags'] ?? []) as $i => $tag) { $tag = trim((string) $tag); if ($tag !== '') { $tagStmt->execute([$slug, $lang, $tag, $i]); } } foreach (array_values($block['ingredients'] ?? []) as $i => $line) { $line = trim((string) $line); if ($line !== '') { $ingredientStmt->execute([$slug, $lang, $line, $i]); } } foreach (array_values($block['utensils'] ?? []) as $i => $line) { $line = trim((string) $line); if ($line !== '') { $utensilStmt->execute([$slug, $lang, $line, $i]); } } $steps = array_values($block['steps'] ?? []); $videos = array_values($block['step_videos'] ?? []); $timers = array_values($block['step_timers'] ?? []); foreach ($steps as $i => $step) { $step = trim((string) $step); if ($step === '') { continue; } $video = trim((string) ($videos[$i] ?? '')); $timerRaw = $timers[$i] ?? ''; $timerMinutes = ($timerRaw !== '' && is_numeric($timerRaw)) ? (int) $timerRaw : null; $stepStmt->execute([$slug, $lang, $step, $video, $timerMinutes, $i]); } } $pdo->commit(); return true; } catch (\Exception $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } error_log('save_recipe failed: ' . $e->getMessage()); return false; } } /** * Default settings for imprint and privacy pages. */ function default_site_settings(): array { $today = date('Y-m-d'); return [ 'imprint' => [ 'de' => [ 'owner_name' => 'FlixCooks (bitte anpassen)', 'address' => 'Straße Hausnummer, PLZ Ort, Österreich', 'email' => 'contact@flixcooks.at', 'phone' => '+43 660 0000000', 'legal_form' => 'Kleines Impressum; kein Firmenbucheintrag/UID hinterlegt.', 'business_purpose' => 'Foodblog & Rezeptmarketing.', 'wko_membership' => '', 'authority' => '', 'uid' => '', 'odr' => 'https://ec.europa.eu/consumers/odr', 'last_updated' => $today, ], 'en' => [ 'owner_name' => 'FlixCooks (please update)', 'address' => 'Street number, ZIP City, Austria', 'email' => 'contact@flixcooks.at', 'phone' => '+43 660 0000000', 'legal_form' => 'Small website notice; no commercial register/UID provided.', 'business_purpose' => 'Food blog & recipe marketing.', 'wko_membership' => '', 'authority' => '', 'uid' => '', 'odr' => 'https://consumer-redress.ec.europa.eu/', 'last_updated' => $today, ], ], 'privacy' => [ 'de' => [ 'controller' => 'FlixCooks (bitte anpassen)', 'contact_email' => 'contact@flixcooks.at', 'contact_phone' => '+43 660 0000000', 'address' => 'Straße Hausnummer, PLZ Ort, Österreich', 'hosting_provider' => 'Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Deutschland', 'log_retention_days' => 30, 'cookie_statement' => 'Keine Tracking- oder Marketing-Cookies; nur technisch notwendige.', 'purposes' => 'Betrieb und Bereitstellung der Website, Beantwortung von Kontaktanfragen.', 'last_updated' => $today, ], 'en' => [ 'controller' => 'FlixCooks (please update)', 'contact_email' => 'contact@flixcooks.at', 'contact_phone' => '+43 660 0000000', 'address' => 'Street number, ZIP City, Austria', 'hosting_provider' => 'Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Germany', 'log_retention_days' => 30, 'cookie_statement' => 'No tracking or marketing cookies; only technically necessary cookies.', 'purposes' => 'Operating and providing the website, responding to contact requests.', 'last_updated' => $today, ], ], ]; } /** * Load site-wide settings for legal pages, merging with defaults. */ function load_site_settings(): array { $defaults = default_site_settings(); $path = __DIR__ . '/data/site.json'; if (!file_exists($path)) { return $defaults; } $raw = file_get_contents($path); $data = json_decode($raw, true); if (!is_array($data)) { return $defaults; } return array_replace_recursive($defaults, $data); } /** * Persist site-wide settings for legal pages. */ function save_site_settings(array $settings): bool { $path = __DIR__ . '/data/site.json'; $json = json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return (bool) file_put_contents($path, $json); } function slugify(string $text): string { $text = strtolower($text); $text = preg_replace('~[^a-z0-9]+~', '-', $text); $text = trim($text, '-'); return $text ?: uniqid('recipe-'); } function find_recipe_by_slug(array $recipes, string $slug): ?array { foreach ($recipes as $recipe) { if (($recipe['slug'] ?? '') === $slug) { return $recipe; } } return null; } function localize_recipe(array $recipe, string $lang): array { $localized = $recipe; $i18n = $recipe['i18n'][$lang] ?? ($recipe['i18n']['en'] ?? []); foreach ($i18n as $key => $value) { $localized[$key] = $value; } return $localized; } function localize_recipes(array $recipes, string $lang): array { return array_map(function ($recipe) use ($lang) { return localize_recipe($recipe, $lang); }, $recipes); } function filter_recipes(array $recipes, ?string $query = null, ?string $tag = null, string $lang = 'en'): array { $query = $query ? strtolower($query) : null; $tag = $tag ? strtolower($tag) : null; $localized = localize_recipes($recipes, $lang); return array_values(array_filter($localized, function ($recipe) use ($query, $tag) { $matchesQuery = true; if ($query) { $haystack = strtolower(($recipe['title'] ?? '') . ' ' . ($recipe['description'] ?? '')); $matchesQuery = strpos($haystack, $query) !== false; } $matchesTag = true; if ($tag) { $tags = array_map('strtolower', $recipe['tags'] ?? []); $matchesTag = in_array($tag, $tags, true); } return $matchesQuery && $matchesTag; })); } function format_minutes(int $minutes): string { if ($minutes < 60) { return $minutes . ' min'; } $hours = intdiv($minutes, 60); $mins = $minutes % 60; return $hours . ' hr' . ($hours > 1 ? 's ' : ' ') . ($mins ? $mins . ' min' : ''); } function e(?string $value): string { return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } function normalize_asset_path(string $path): string { if (!str_starts_with($path, '/assets/')) { return $path; } $absolute = __DIR__ . $path; if (file_exists($absolute)) { return $path; } $dir = dirname($absolute); $target = basename($absolute); if (!is_dir($dir)) { return $path; } foreach (scandir($dir) ?: [] as $entry) { if (strcasecmp($entry, $target) === 0) { return rtrim(dirname($path), '/') . '/' . $entry; } } return $path; } /** * Build a URL to the current path with a specific language parameter, preserving other query params. */ function lang_url(string $lang): string { $params = $_GET; $params['lang'] = $lang; $path = strtok($_SERVER['REQUEST_URI'], '?') ?: '/'; return $path . '?' . http_build_query($params); } // Fallback polyfills for environments without mbstring extension if (!function_exists('mb_substr')) { function mb_substr(string $string, int $start, ?int $length = null, ?string $encoding = null): string { return substr($string, $start, $length ?? strlen($string)); } } if (!function_exists('mb_strlen')) { function mb_strlen(string $string, ?string $encoding = null): int { return strlen($string); } }