false, 'message' => 'Forbidden.']); exit; } if ($origin) { header('Access-Control-Allow-Origin: ' . $origin); header('Access-Control-Allow-Methods: POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); header('Access-Control-Allow-Credentials: true'); } if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } // ── Helpers ────────────────────────────────────────────────────────────────── function json_out(array $data, int $status = 200): never { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data); exit; } function start_session(): void { if (session_status() === PHP_SESSION_NONE) { session_name(SESSION_NAME); session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', 'secure' => SESSION_SECURE, 'httponly' => true, 'samesite' => SESSION_SAMESITE, ]); session_start(); } } function db_connect(): PDO { static $pdo = null; if ($pdo !== null) return $pdo; $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', DB_HOST, DB_PORT, DB_NAME); try { $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, ]); } catch (PDOException $e) { error_log('DB connection failed: ' . $e->getMessage()); json_out(['success' => false, 'message' => 'Database unavailable. Please try again later.'], 503); } return $pdo; } // ── Only accept POST ────────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] !== 'POST') { json_out(['success' => false, 'message' => 'Method not allowed.'], 405); } // ── Parse JSON body ─────────────────────────────────────────────────────────── $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { json_out(['success' => false, 'message' => 'Invalid request body.'], 400); } $action = $body['action'] ?? ''; // ── Rate limiting (per-IP via session) ──────────────────────────────────────── start_session(); $now = time(); $rl_key = 'rl_' . hash('sha256', $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); $rl = $_SESSION[$rl_key] ?? ['count' => 0, 'window_start' => $now]; if ($now - $rl['window_start'] > 900) { $rl = ['count' => 0, 'window_start' => $now]; } $rl['count']++; $_SESSION[$rl_key] = $rl; if ($rl['count'] > 20) { json_out(['success' => false, 'message' => 'Too many requests. Please wait a few minutes.'], 429); } // ════════════════════════════════════════════════════════════════════════════ // ACTION: login // ════════════════════════════════════════════════════════════════════════════ if ($action === 'login') { $username = trim($body['username'] ?? ''); $password = $body['password'] ?? ''; if ($username === '' || $password === '') { json_out(['success' => false, 'message' => 'Username and password are required.']); } $pdo = db_connect(); $stmt = $pdo->prepare('SELECT id, username, password_hash FROM users WHERE username = :u LIMIT 1'); $stmt->execute([':u' => $username]); $user = $stmt->fetch(); $hash = $user['password_hash'] ?? '$2y$12$invalidhashpadding000000000000000000000000000000000000000'; if (!$user || !password_verify($password, $hash)) { json_out(['success' => false, 'message' => 'Invalid username or password.']); } session_regenerate_id(true); $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; json_out(['success' => true, 'redirect' => '/']); } // ════════════════════════════════════════════════════════════════════════════ // ACTION: signup // ════════════════════════════════════════════════════════════════════════════ if ($action === 'signup') { $username = trim($body['username'] ?? ''); $email = trim($body['email'] ?? ''); $password = $body['password'] ?? ''; if ($username === '') { json_out(['success' => false, 'message' => 'Username is required.']); } if (!preg_match('/^[a-zA-Z0-9_\-]{3,32}$/', $username)) { json_out(['success' => false, 'message' => 'Username must be 3–32 characters: letters, numbers, _ or -.']); } if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { json_out(['success' => false, 'message' => 'A valid email address is required.']); } if (strlen($password) < 8) { json_out(['success' => false, 'message' => 'Password must be at least 8 characters.']); } $pdo = db_connect(); $stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :u OR email = :e LIMIT 1'); $stmt->execute([':u' => $username, ':e' => $email]); if ($stmt->fetch()) { json_out(['success' => false, 'message' => 'Username or email is already taken.']); } $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); $stmt = $pdo->prepare( 'INSERT INTO users (username, email, password_hash, created_at) VALUES (:u, :e, :h, NOW())' ); $stmt->execute([':u' => $username, ':e' => $email, ':h' => $hash]); json_out(['success' => true]); } // ════════════════════════════════════════════════════════════════════════════ // ACTION: logout // ════════════════════════════════════════════════════════════════════════════ if ($action === 'logout') { $_SESSION = []; if (ini_get('session.use_cookies')) { $p = session_get_cookie_params(); setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']); } session_destroy(); json_out(['success' => true, 'redirect' => '/']); } json_out(['success' => false, 'message' => 'Unknown action.'], 400);