'unauthorized', 'message' => 'Ugyldig admin-token.'], 401); } return $provided; } function adminDb(string $credential): PDO { try { return bmc_db(); } catch (RuntimeException $e) { if (!str_contains($e->getMessage(), 'environment variables are missing')) { throw $e; } } return new PDO( 'mysql:host=127.0.0.1;port=3306;dbname=bmcnetworks_26;charset=utf8mb4', 'bmc_26dcrhccr', $credential, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] ); } function body(): array { $decoded = json_decode(file_get_contents('php://input') ?: '{}', true); if (!is_array($decoded)) { bmc_json_response(['error' => 'invalid_json', 'message' => 'Ugyldig JSON.'], 400); } return $decoded; } function resourceConfig(string $resource): array { $configs = [ 'customers' => [ 'table' => 'customer_references', 'fields' => ['customer_name', 'logo_url', 'website_url', 'sort_order', 'is_active'], 'required' => ['customer_name'], 'visibility' => 'is_active', 'order' => 'sort_order ASC, customer_name ASC', 'select' => 'id, customer_name, logo_url, website_url, sort_order, is_active, source, updated_at', ], 'operations' => [ 'table' => 'operations_status', 'fields' => ['title', 'severity', 'message', 'starts_at', 'ends_at', 'is_active'], 'required' => ['title', 'message'], 'visibility' => 'is_active', 'order' => 'updated_at DESC', 'select' => 'id, title, severity, message, starts_at, ends_at, is_active, source, updated_at', ], 'incidents' => [ 'table' => 'operations_incidents', 'fields' => ['title', 'severity', 'message', 'starts_at', 'ends_at', 'is_public'], 'required' => ['title', 'message'], 'visibility' => 'is_public', 'order' => 'updated_at DESC', 'select' => 'id, title, severity, message, starts_at, ends_at, is_public, source, updated_at', ], ]; if (!isset($configs[$resource])) { bmc_json_response(['error' => 'invalid_resource'], 404); } return $configs[$resource]; } function cleanValues(array $input, array $config, bool $creating): array { $values = []; foreach ($config['fields'] as $field) { if (array_key_exists($field, $input)) { $value = $input[$field]; if (in_array($field, ['is_active', 'is_public'], true)) { $value = $value ? 1 : 0; } if ($field === 'sort_order') { $value = max(0, (int)$value); } if ($field === 'severity' && !in_array($value, ['ok', 'info', 'warning', 'critical'], true)) { bmc_json_response(['error' => 'validation_failed', 'message' => 'Ugyldig severity.'], 422); } if (in_array($field, ['starts_at', 'ends_at', 'website_url'], true) && $value === '') { $value = null; } $values[$field] = $value; } } if ($creating) { foreach ($config['required'] as $field) { if (!isset($values[$field]) || trim((string)$values[$field]) === '') { bmc_json_response(['error' => 'validation_failed', 'message' => "$field mangler."], 422); } } } return $values; } function fetchItem(PDO $pdo, array $config, int $id): array { $statement = $pdo->prepare("SELECT {$config['select']} FROM {$config['table']} WHERE id = ? LIMIT 1"); $statement->execute([$id]); $item = $statement->fetch(); if (!$item) { bmc_json_response(['error' => 'not_found'], 404); } return $item; } function atomicWrite(string $path, string $contents): void { $temporary = $path . '.tmp.' . bin2hex(random_bytes(6)); if (file_put_contents($temporary, $contents, LOCK_EX) === false || !rename($temporary, $path)) { @unlink($temporary); throw new RuntimeException('Kunne ikke opdatere den offentlige content-cache.'); } } function refreshPublicCache(PDO $pdo): void { $customers = $pdo->query( 'SELECT customer_name, logo_url, website_url FROM customer_references WHERE is_active = 1 ORDER BY sort_order ASC, customer_name ASC LIMIT 50' )->fetchAll(); $current = $pdo->query( 'SELECT title, severity, message, starts_at, ends_at, updated_at FROM operations_status WHERE is_active = 1 AND (starts_at IS NULL OR starts_at <= NOW()) AND (ends_at IS NULL OR ends_at >= NOW()) ORDER BY updated_at DESC LIMIT 1' )->fetch() ?: null; $history = $pdo->query( 'SELECT title, severity, message, starts_at, ends_at, updated_at FROM operations_incidents WHERE is_public = 1 ORDER BY updated_at DESC LIMIT 20' )->fetchAll(); $json = json_encode([ 'meta' => ['generated_at' => gmdate('c')], 'customers' => $customers, 'operations' => ['current' => $current, 'history' => $history], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); atomicWrite(__DIR__ . '/content-cache.json', $json); $logoDirectory = __DIR__ . '/content-cache-logos'; if (!is_dir($logoDirectory) && !mkdir($logoDirectory, 0755, true) && !is_dir($logoDirectory)) { throw new RuntimeException('Kunne ikke oprette logo-cache.'); } $extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif']; $logos = $pdo->query( 'SELECT id, logo_blob, logo_mime_type FROM customer_references WHERE is_active = 1 AND logo_blob IS NOT NULL' )->fetchAll(); $activeFiles = []; foreach ($logos as $logo) { $extension = $extensions[(string)$logo['logo_mime_type']] ?? null; if ($extension === null || !is_string($logo['logo_blob'])) { continue; } $filename = (int)$logo['id'] . '.' . $extension; atomicWrite($logoDirectory . '/' . $filename, $logo['logo_blob']); $activeFiles[$filename] = true; } foreach (glob($logoDirectory . '/*.{png,jpg,webp,gif}', GLOB_BRACE) ?: [] as $cachedLogo) { if (!isset($activeFiles[basename($cachedLogo)])) { @unlink($cachedLogo); } } } function outputLogo(PDO $pdo, int $id): void { $statement = $pdo->prepare('SELECT logo_blob, logo_mime_type FROM customer_references WHERE id = ? LIMIT 1'); $statement->execute([$id]); $logo = $statement->fetch(); if (!$logo || !is_string($logo['logo_blob'])) { bmc_json_response(['error' => 'not_found'], 404); } header('Content-Type: ' . ($logo['logo_mime_type'] ?: 'application/octet-stream')); header('Content-Length: ' . strlen($logo['logo_blob'])); header('Cache-Control: private, max-age=60'); header('X-Content-Type-Options: nosniff'); echo $logo['logo_blob']; exit; } function uploadLogo(PDO $pdo, int $id, array $config): void { if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) { bmc_json_response(['error' => 'invalid_upload', 'message' => 'Logo mangler.'], 422); } $file = $_FILES['logo']; if ((int)$file['size'] < 1 || (int)$file['size'] > MAX_LOGO_BYTES) { bmc_json_response(['error' => 'file_too_large', 'message' => 'Logo må højst fylde 5 MB.'], 413); } $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']); if (!in_array($mime, ALLOWED_LOGO_TYPES, true)) { bmc_json_response(['error' => 'invalid_file_type'], 415); } $blob = file_get_contents($file['tmp_name']); $logoUrl = '/api/content.php?logo=' . $id; $statement = $pdo->prepare( 'UPDATE customer_references SET logo_blob = ?, logo_mime_type = ?, logo_url = ? WHERE id = ?' ); $statement->bindParam(1, $blob, PDO::PARAM_LOB); $statement->bindValue(2, $mime); $statement->bindValue(3, $logoUrl); $statement->bindValue(4, $id, PDO::PARAM_INT); $statement->execute(); refreshPublicCache($pdo); bmc_json_response(fetchItem($pdo, $config, $id)); } $adminCredential = requireAdminToken(); $resource = (string)($_GET['resource'] ?? ''); $id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) ?: null; $action = (string)($_GET['action'] ?? ''); $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); $config = resourceConfig($resource); try { $pdo = adminDb($adminCredential); if ($resource === 'customers' && $id && $action === 'logo') { if ($method === 'GET') { outputLogo($pdo, $id); } uploadLogo($pdo, $id, $config); } // A normal authenticated read also repairs/initializes the public cache. refreshPublicCache($pdo); if ($resource === 'operations' && $id && $action === 'complete' && $method === 'POST') { $input = body(); $endedAt = $input['ends_at'] ?: date('Y-m-d H:i:s'); $pdo->beginTransaction(); try { $statement = $pdo->prepare('SELECT * FROM operations_status WHERE id = ? FOR UPDATE'); $statement->execute([$id]); $operation = $statement->fetch(); if (!$operation) { $pdo->rollBack(); bmc_json_response(['error' => 'not_found'], 404); } $insert = $pdo->prepare( "INSERT INTO operations_incidents (title, severity, message, starts_at, ends_at, is_public, source) VALUES (?, ?, ?, ?, ?, ?, 'hub')" ); $insert->execute([ $operation['title'], $operation['severity'], $operation['message'], $operation['starts_at'], $endedAt, !empty($input['is_public']) ? 1 : 0, ]); $incidentId = (int)$pdo->lastInsertId(); $pdo->prepare('UPDATE operations_status SET is_active = 0, ends_at = ? WHERE id = ?') ->execute([$endedAt, $id]); $pdo->commit(); refreshPublicCache($pdo); bmc_json_response(fetchItem($pdo, resourceConfig('incidents'), $incidentId), 201); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $e; } } if ($method === 'GET' && $id) { bmc_json_response(fetchItem($pdo, $config, $id)); } if ($method === 'GET') { $where = !filter_var($_GET['include_hidden'] ?? true, FILTER_VALIDATE_BOOL) ? " WHERE {$config['visibility']} = 1" : ''; $items = $pdo->query("SELECT {$config['select']} FROM {$config['table']}{$where} ORDER BY {$config['order']}") ->fetchAll(); bmc_json_response(['items' => $items]); } if ($method === 'POST' && !$id) { $values = cleanValues(body(), $config, true); $values['source'] = 'hub'; if ($resource === 'customers' && empty($values['logo_url'])) { $values['logo_url'] = ''; } $columns = array_keys($values); $sql = "INSERT INTO {$config['table']} (" . implode(',', $columns) . ') VALUES (' . implode(',', array_fill(0, count($columns), '?')) . ')'; $pdo->prepare($sql)->execute(array_values($values)); $newId = (int)$pdo->lastInsertId(); if ($resource === 'customers' && $values['logo_url'] === '') { $pdo->prepare('UPDATE customer_references SET logo_url = ? WHERE id = ?') ->execute(['/api/content.php?logo=' . $newId, $newId]); } refreshPublicCache($pdo); bmc_json_response(fetchItem($pdo, $config, $newId), 201); } if ($method === 'PATCH' && $id) { $values = cleanValues(body(), $config, false); if (!$values) { bmc_json_response(fetchItem($pdo, $config, $id)); } $assignments = implode(',', array_map(fn($field) => "$field = ?", array_keys($values))); $pdo->prepare("UPDATE {$config['table']} SET $assignments WHERE id = ?") ->execute([...array_values($values), $id]); refreshPublicCache($pdo); bmc_json_response(fetchItem($pdo, $config, $id)); } bmc_json_response(['error' => 'method_not_allowed'], 405); } catch (Throwable $e) { error_log('admin-content.php: ' . $e->getMessage()); bmc_json_response([ 'error' => 'content_admin_unavailable', 'message' => 'Website-databasen er ikke tilgængelig: ' . $e->getMessage(), ], 503); }