'Internal server error', 'detail' => $err['message']]); } }); // Start session once, safely // For OAuth flows (cross-site redirect), SameSite must be Lax (not Strict) function start_session(): void { if (session_status() === PHP_SESSION_NONE) { $is_https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443); session_set_cookie_params([ 'lifetime' => 86400, 'path' => '/', 'secure' => $is_https, 'httponly' => true, 'samesite' => 'Lax', // Lax allows cookie on redirect from OAuth provider ]); session_start(); } } function db(): PDO { static $pdo = null; if ($pdo === null) { $dsn = sprintf( 'pgsql:host=%s;port=%s;dbname=%s', DB_HOST, DB_PORT, DB_NAME ); $pdo = new PDO($dsn, DB_USER, DB_PASS, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); } return $pdo; } function json_out(mixed $data, int $status = 200): never { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function json_error(string $msg, int $status = 400): never { json_out(['error' => $msg], $status); } function cors(): void { $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; // credentials:include requires explicit origin, not wildcard if ($origin !== '') { header('Access-Control-Allow-Origin: ' . $origin); } else { header('Access-Control-Allow-Origin: *'); } header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); header('Access-Control-Allow-Credentials: true'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } } function require_admin(): void { start_session(); if (empty($_SESSION['is_admin'])) { json_error('Unauthorized', 401); } } function is_moderator(): bool { start_session(); if (!empty($_SESSION['is_admin'])) return true; $user = $_SESSION['oauth_user'] ?? null; if (!$user) return false; $stmt = db()->prepare(" SELECT 1 FROM moderators m JOIN users u ON u.id = m.user_id WHERE m.user_id = :uid AND (u.banned IS NULL OR u.banned = false) "); $stmt->execute([':uid' => $user['id']]); return (bool)$stmt->fetchColumn(); } function require_mod(): void { if (!is_moderator()) { json_error('Unauthorized — moderator access required', 401); } } function body(): array { $raw = file_get_contents('php://input'); return json_decode($raw, true) ?? []; } function sanitize_name(string $name): string { return strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', trim($name))); }