Dev #9
@@ -34,13 +34,42 @@ function load_env() {
|
|||||||
// Automatically load on include
|
// Automatically load on include
|
||||||
load_env();
|
load_env();
|
||||||
|
|
||||||
function get_firebase_config(): array {
|
/**
|
||||||
return [
|
* Get a PDO connection to the database.
|
||||||
'apiKey' => getenv('FIREBASE_API_KEY') ?: '',
|
*/
|
||||||
'authDomain' => getenv('FIREBASE_AUTH_DOMAIN') ?: '',
|
function get_db_connection(): ?PDO {
|
||||||
'projectId' => getenv('FIREBASE_PROJECT_ID') ?: '',
|
static $pdo = null;
|
||||||
'storageBucket' => getenv('FIREBASE_STORAGE_BUCKET') ?: '',
|
|
||||||
'messagingSenderId' => getenv('FIREBASE_MESSAGING_SENDER_ID') ?: '',
|
if ($pdo !== null) {
|
||||||
'appId' => getenv('FIREBASE_APP_ID') ?: '',
|
return $pdo;
|
||||||
];
|
}
|
||||||
|
|
||||||
|
$url = getenv('DATABASE_URL');
|
||||||
|
if (!$url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsedUrl = parse_url($url);
|
||||||
|
if ($parsedUrl === false || !isset($parsedUrl['host'], $parsedUrl['user'], $parsedUrl['pass'], $parsedUrl['path'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = $parsedUrl['host'];
|
||||||
|
$port = $parsedUrl['port'] ?? 5432;
|
||||||
|
$user = $parsedUrl['user'];
|
||||||
|
$pass = $parsedUrl['pass'];
|
||||||
|
$db = ltrim($parsedUrl['path'], '/');
|
||||||
|
|
||||||
|
$dsn = "pgsql:host=$host;port=$port;dbname=$db";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO($dsn, $user, $pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
return $pdo;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log("Database connection failed: " . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,54 +7,72 @@ if (session_status() === PHP_SESSION_NONE) {
|
|||||||
|
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
function decode_firestore_value($value) {
|
function init_db() {
|
||||||
if (!is_array($value)) return $value;
|
$pdo = get_db_connection();
|
||||||
if (isset($value['stringValue'])) return $value['stringValue'];
|
if (!$pdo) return;
|
||||||
if (isset($value['integerValue'])) return (int)$value['integerValue'];
|
|
||||||
if (isset($value['doubleValue'])) return (float)$value['doubleValue'];
|
|
||||||
if (isset($value['booleanValue'])) return (bool)$value['booleanValue'];
|
|
||||||
if (isset($value['nullValue'])) return null;
|
|
||||||
if (isset($value['arrayValue']['values'])) {
|
|
||||||
return array_map('decode_firestore_value', $value['arrayValue']['values']);
|
|
||||||
}
|
|
||||||
if (isset($value['mapValue']['fields'])) {
|
|
||||||
return array_map('decode_firestore_value', $value['mapValue']['fields']);
|
|
||||||
}
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decode_firestore_document(array $doc): array {
|
try {
|
||||||
if (!isset($doc['fields'])) return [];
|
$pdo->exec("CREATE TABLE IF NOT EXISTS recipes (
|
||||||
return array_map('decode_firestore_value', $doc['fields']);
|
slug VARCHAR(255) PRIMARY KEY,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)");
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT COUNT(*) FROM recipes");
|
||||||
|
if ($stmt && $stmt->fetchColumn() == 0) {
|
||||||
|
$path = __DIR__ . '/data/recipes.json';
|
||||||
|
if (file_exists($path)) {
|
||||||
|
|
|||||||
|
$json = file_get_contents($path);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
if (is_array($data) && count($data) > 0) {
|
||||||
|
Loading the schema from an external SQL file ( Loading the schema from an external SQL file (`scripts/schema.sql`) is a good practice for maintainability and readability. Throwing a `RuntimeException` if the file is missing is appropriate.
|
|||||||
|
$insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data)");
|
||||||
|
foreach ($data as $recipe) {
|
||||||
|
if (isset($recipe['slug'])) {
|
||||||
|
$insert->execute([
|
||||||
|
'slug' => $recipe['slug'],
|
||||||
|
'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE)
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function firestore_get(string $url): ?array {
|
|
||||||
$ch = curl_init();
|
|
||||||
curl_setopt($ch, CURLOPT_URL, $url);
|
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($httpCode !== 200 || !$response) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return json_decode($response, true);
|
}
|
||||||
|
}
|
||||||
|
This helper function provides a clean default structure for internationalized recipe data, ensuring consistency. This helper function provides a clean default structure for internationalized recipe data, ensuring consistency.
|
|||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log("DB Init Error: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
The default value for 'description' is an empty string. While functional, for internationalization, it's often beneficial to provide a placeholder string like The default value for 'description' is an empty string. While functional, for internationalization, it's often beneficial to provide a placeholder string like `null` or a specific marker (e.g., `__('default_description')`) that can be explicitly translated or identified as missing. This can help in debugging or ensuring all fields are eventually populated.
|
|||||||
}
|
}
|
||||||
|
Similar to the 'description', an empty string for 'category' might be better represented by Similar to the 'description', an empty string for 'category' might be better represented by `null` or a translatable placeholder to distinguish between an intentionally empty category and a missing one.
|
|||||||
|
|
||||||
|
Similar to 'description' and 'category', an empty string for 'difficulty' could be improved by using Similar to 'description' and 'category', an empty string for 'difficulty' could be improved by using `null` or a translatable placeholder. This helps differentiate between an unset difficulty and a deliberately empty one.
|
|||||||
function load_recipes_local(): array {
|
function load_recipes_local(): array {
|
||||||
|
$pdo = get_db_connection();
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
|
It's good that It's good that `step_videos` is initialized as an empty array. However, the `save_recipe` function inserts `video_url` from the input directly. If the input `step_videos` array contains `null` or empty strings, they will be inserted as such. Consider adding a trim or filter for empty strings here if they are not intended to be stored.
|
|||||||
$path = __DIR__ . '/data/recipes.json';
|
$path = __DIR__ . '/data/recipes.json';
|
||||||
|
Similar to Similar to `step_videos`, `step_timers` is initialized as an empty array. The `save_recipe` function converts timer raw values to `int` or `null`. If the input array contains non-numeric strings, they will result in `null`. Ensure that any non-numeric or empty string values are handled consistently, perhaps by filtering them out before insertion.
|
|||||||
if (!file_exists($path)) {
|
if (file_exists($path)) {
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$json = file_get_contents($path);
|
$json = file_get_contents($path);
|
||||||
$data = json_decode($json, true);
|
$data = json_decode($json, true) ?: [];
|
||||||
if (!is_array($data)) {
|
}
|
||||||
|
This function correctly extracts base recipe data from a database row, handling potential missing keys with default values. The explicit type casting ( This function correctly extracts base recipe data from a database row, handling potential missing keys with default values. The explicit type casting (`(int)`, `(bool)`) is good for data integrity.
|
|||||||
return [];
|
} else {
|
||||||
|
static $initialized = false;
|
||||||
|
if (!$initialized) {
|
||||||
|
init_db();
|
||||||
|
$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("SELECT data FROM recipes");
|
||||||
|
if ($stmt) {
|
||||||
|
while ($row = $stmt->fetch()) {
|
||||||
|
$recipe = json_decode($row['data'], true);
|
||||||
|
if (is_array($recipe)) {
|
||||||
|
$data[] = $recipe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log("Failed to load recipes from DB: " . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($data as &$recipe) {
|
foreach ($data as &$recipe) {
|
||||||
|
This function attempts to decode legacy JSONB values. It handles arrays, strings, and objects that can be JSON encoded. However, it might be beneficial to add explicit handling or error logging for unexpected data types passed to This function attempts to decode legacy JSONB values. It handles arrays, strings, and objects that can be JSON encoded. However, it might be beneficial to add explicit handling or error logging for unexpected data types passed to `$value` to prevent potential `TypeError` or other runtime errors if the input is not as expected.
|
|||||||
@@ -68,59 +86,61 @@ function load_recipes_local(): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function load_recipes(): array {
|
function load_recipes(): array {
|
||||||
$config = get_firebase_config();
|
|
||||||
$projectId = $config['projectId'] ?? '';
|
|
||||||
if (empty($projectId)) {
|
|
||||||
return load_recipes_local();
|
return load_recipes_local();
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = "https://firestore.googleapis.com/v1/projects/{$projectId}/databases/(default)/documents/recipes?pageSize=100";
|
|
||||||
$res = firestore_get($url);
|
|
||||||
if (!$res || !isset($res['documents'])) {
|
|
||||||
return load_recipes_local();
|
|
||||||
}
|
|
||||||
|
|
||||||
$recipes = [];
|
|
||||||
foreach ($res['documents'] as $doc) {
|
|
||||||
$decoded = decode_firestore_document($doc);
|
|
||||||
if (!empty($decoded)) {
|
|
||||||
if (!empty($decoded['hero']) && is_string($decoded['hero'])) {
|
|
||||||
$decoded['hero'] = normalize_asset_path($decoded['hero']);
|
|
||||||
}
|
|
||||||
$recipes[] = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $recipes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function load_recipe_by_slug(string $slug): ?array {
|
function load_recipe_by_slug(string $slug): ?array {
|
||||||
$config = get_firebase_config();
|
|
||||||
$projectId = $config['projectId'] ?? '';
|
|
||||||
if (empty($projectId)) {
|
|
||||||
return find_recipe_by_slug(load_recipes_local(), $slug);
|
return find_recipe_by_slug(load_recipes_local(), $slug);
|
||||||
}
|
}
|
||||||
|
This check correctly identifies if the legacy 'data' column exists, preventing unnecessary migration steps. This is a robust way to handle schema evolution. This check correctly identifies if the legacy 'data' column exists, preventing unnecessary migration steps. This is a robust way to handle schema evolution.
|
|||||||
|
|
||||||
$url = "https://firestore.googleapis.com/v1/projects/{$projectId}/databases/(default)/documents/recipes/" . urlencode($slug);
|
|
||||||
$res = firestore_get($url);
|
|
||||||
if (!$res || isset($res['error'])) {
|
|
||||||
return find_recipe_by_slug(load_recipes_local(), $slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = decode_firestore_document($res);
|
|
||||||
if (!empty($decoded['hero']) && is_string($decoded['hero'])) {
|
|
||||||
$decoded['hero'] = normalize_asset_path($decoded['hero']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
function save_recipes(array $recipes): bool {
|
function save_recipes(array $recipes): bool {
|
||||||
|
$pdo = get_db_connection();
|
||||||
$path = __DIR__ . '/data/recipes.json';
|
$path = __DIR__ . '/data/recipes.json';
|
||||||
|
|
||||||
|
if (!$pdo) {
|
||||||
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
return (bool) file_put_contents($path, $json);
|
return (bool) file_put_contents($path, $json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$slugs = [];
|
||||||
|
Dropping existing tables before applying the schema is a common migration strategy. However, if this script is ever run on a production database with existing data, this will result in data loss. Ensure this is only intended for development/testing environments or that a proper migration system is in place for production. Dropping existing tables before applying the schema is a common migration strategy. However, if this script is ever run on a production database with existing data, this will result in data loss. Ensure this is only intended for development/testing environments or that a proper migration system is in place for production.
|
|||||||
|
$insert = $pdo->prepare("INSERT INTO recipes (slug, data) VALUES (:slug, :data) ON CONFLICT (slug) DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP");
|
||||||
|
|
||||||
|
foreach ($recipes as $recipe) {
|
||||||
|
if (isset($recipe['slug'])) {
|
||||||
|
$slugs[] = $recipe['slug'];
|
||||||
|
$insert->execute([
|
||||||
|
'slug' => $recipe['slug'],
|
||||||
|
'data' => json_encode($recipe, JSON_UNESCAPED_UNICODE)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($slugs)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($slugs), '?'));
|
||||||
|
Using a static variable Using a static variable `$ready` to ensure schema application only runs once per request is an efficient optimization. This prevents redundant database operations.
|
|||||||
|
$delete = $pdo->prepare("DELETE FROM recipes WHERE slug NOT IN ($placeholders)");
|
||||||
|
$delete->execute($slugs);
|
||||||
|
} else {
|
||||||
|
$pdo->exec("DELETE FROM recipes");
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
// Also update local JSON as backup
|
||||||
|
$json = json_encode($recipes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
file_put_contents($path, $json);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
This function is crucial for database connectivity and schema management. The fallback logic for This function is crucial for database connectivity and schema management. The fallback logic for `DATABASE_URL` being unset is good. Throwing a `DatabaseUnavailableException` is a clear way to signal a critical error.
|
|||||||
|
}
|
||||||
|
error_log("Failed to save recipes to DB: " . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default settings for imprint and privacy pages.
|
* Default settings for imprint and privacy pages.
|
||||||
*/
|
*/
|
||||||
|
|||||||
This is a concise and efficient way to check for the existence of a column in
information_schema.columns.