'method_not_allowed'], 405); } $auth = ApiAuth::bearerClaims(); ApiAuth::requireRole($auth, ['owner', 'content_manager']); try { $db = Database::getInstance(); if ($method === 'GET') { $result = $db->getConnection()->query('SELECT setting_key, setting_value, is_public, revision, updated_at FROM app_settings ORDER BY setting_key'); $settings = []; while ($row = $result->fetch_assoc()) { $settings[] = [ 'key' => $row['setting_key'], 'value' => json_decode($row['setting_value'], true), 'is_public' => (bool) $row['is_public'], 'revision' => (int) $row['revision'], 'updated_at' => $row['updated_at'], ]; } api_json(['settings' => $settings]); } $body = json_decode(file_get_contents('php://input') ?: '', true); if (!is_array($body) || !is_array($body['settings'] ?? null) || count($body['settings']) > 100) { api_json(['error' => 'invalid_settings_payload'], 400); } $connection = $db->getConnection(); $connection->begin_transaction(); $read = $db->prepare('SELECT setting_value, is_public, revision FROM app_settings WHERE setting_key = ? FOR UPDATE'); $write = $db->prepare('INSERT INTO app_settings (setting_key, setting_value, is_public, revision, updated_by) VALUES (?, ?, ?, 1, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), is_public = VALUES(is_public), revision = revision + 1, updated_by = VALUES(updated_by)'); $audit = $db->prepare('INSERT INTO app_setting_audit (setting_key, previous_value, new_value, actor_user_id) VALUES (?, ?, ?, ?)'); foreach ($body['settings'] as $item) { if (!is_array($item) || !is_string($item['key'] ?? null) || !preg_match('/^[a-z][a-z0-9_.-]{0,99}$/', $item['key']) || !array_key_exists('value', $item) || !is_bool($item['is_public'] ?? null)) { $connection->rollback(); api_json(['error' => 'invalid_setting'], 400); } $key = $item['key']; if (preg_match('/(secret|password|token|credential|private.?key|api.?key)/i', $key)) { $connection->rollback(); api_json(['error' => 'secrets_must_use_server_environment'], 400); } $encodedValue = json_encode($item['value'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($encodedValue === false || strlen($encodedValue) > 65535) { $connection->rollback(); api_json(['error' => 'setting_value_too_large'], 400); } $read->bind_param('s', $key); $read->execute(); $previous = $read->get_result()->fetch_assoc(); $isPublic = $item['is_public'] ? 1 : 0; $actor = $auth['user_id']; $write->bind_param('ssii', $key, $encodedValue, $isPublic, $actor); $write->execute(); $previousValue = $previous['setting_value'] ?? null; $audit->bind_param('sssi', $key, $previousValue, $encodedValue, $actor); $audit->execute(); } $read->close(); $write->close(); $audit->close(); $connection->commit(); api_json(['status' => 'updated']); } catch (Throwable $exception) { if (isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('Admin settings update failed: ' . $exception->getMessage()); api_json(['error' => 'service_unavailable'], 503); }