bmc_hub/deploy/website/admin-content.php

345 lines
13 KiB
PHP
Raw Normal View History

2026-08-28 20:49:55 +02:00
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
header('X-BMC-Admin-Version: 8');
const MAX_LOGO_BYTES = 5242880;
const ALLOWED_LOGO_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
const DEFAULT_ADMIN_TOKEN_HASH = '57ea24fcc6bcfe492247be0051915deb2e735910a9c16fc58b22f273987fafb3';
function adminTokenHash(): string
{
return DEFAULT_ADMIN_TOKEN_HASH;
}
function requireAdminToken(): string
{
$expectedHash = adminTokenHash();
$provided = trim((string)($_SERVER['HTTP_X_WEBSITE_ADMIN_TOKEN'] ?? ''));
if ($expectedHash === '' || $provided === '' || !hash_equals($expectedHash, hash('sha256', $provided))) {
bmc_json_response(['error' => '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);
}