$v) { $out[strtolower($k)] = $v; } // Fallbacks from $_SERVER if needed foreach ($_SERVER as $k => $v) { if (strpos($k, 'HTTP_') === 0) { $h = strtolower(str_replace('_', '-', substr($k, 5))); if (!isset($out[$h])) $out[$h] = $v; } } return $out; } /** Enforce allowed HTTP method(s); default to 405 if not allowed. */ private static function requireMethod(array $allowed): void { $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; if (!in_array($method, $allowed, true)) { http_response_code(405); header('Allow: ' . implode(', ', $allowed)); header('Content-Type: application/json'); echo json_encode(['error' => 'Method not allowed']); exit; } } /** Enforce authentication (401). */ private static function requireAuth(): void { if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['error' => 'Unauthorized']); exit; } } /** Enforce admin (401). */ private static function requireAdmin(): void { self::requireAuth(); // Prefer the session flag $isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true); // Fallback: check the user’s role in storage (e.g., users.txt/DB) if (!$isAdmin) { $u = $_SESSION['username'] ?? ''; if ($u) { try { // UserModel::getUserRole($u) should return '1' for admins $isAdmin = (UserModel::getUserRole($u) === '1'); if ($isAdmin) { // Normalize session so downstream ACL checks see admin $_SESSION['isAdmin'] = true; } } catch (\Throwable $e) { // ignore and continue to deny } } } if (!$isAdmin) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Admin privileges required.']); exit; } } /** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */ private static function requireCsrf(): void { $h = self::headersLower(); $token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? '')); if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['error' => 'Invalid CSRF token']); exit; } } /** Read JSON body (empty array if not valid). */ private static function readJson(): array { $raw = file_get_contents('php://input'); $data = json_decode($raw, true); return is_array($data) ? $data : []; } /** Convenience: set JSON content type + no-store. */ private static function jsonHeaders(): void { header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); } /* ------------------------- End helpers -------------------------- */ public function getUsers() { self::jsonHeaders(); self::requireAdmin(); // Retrieve users using the model $users = UserModel::getAllUsers(); echo json_encode($users); exit; } public function addUser() { self::jsonHeaders(); self::requireMethod(['POST']); // Initialize CSRF token if missing (useful for initial page load) if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } // Setup mode detection (first-run bootstrap) $usersFile = USERS_DIR . USERS_FILE; $isSetup = (isset($_GET['setup']) && $_GET['setup'] === '1'); $setupMode = false; if ( $isSetup && (!file_exists($usersFile) || filesize($usersFile) === 0 || trim(@file_get_contents($usersFile)) === '' ) ) { $setupMode = true; } else { // Not setup: enforce CSRF + admin auth $h = self::headersLower(); $receivedToken = trim($h['x-csrf-token'] ?? ''); // Soft-fail CSRF: on mismatch, regenerate and return new token (preserve your current UX) if ($receivedToken !== $_SESSION['csrf_token']) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); header('X-CSRF-Token: ' . $_SESSION['csrf_token']); echo json_encode([ 'csrf_expired' => true, 'csrf_token' => $_SESSION['csrf_token'] ]); exit; } self::requireAdmin(); } $data = self::readJson(); $newUsername = trim($data['username'] ?? ''); $newPassword = trim($data['password'] ?? ''); $isAdmin = $setupMode ? '1' : (!empty($data['isAdmin']) ? '1' : '0'); if ($newUsername === '' || $newPassword === '') { echo json_encode(["error" => "Username and password required"]); exit; } if (!preg_match(REGEX_USER, $newUsername)) { echo json_encode([ "error" => "Invalid username. Only letters, numbers, underscores, dashes, and spaces are allowed." ]); exit; } // Keep password rules lenient to avoid breaking existing flows; enforce at least 6 chars if (strlen($newPassword) < 6) { echo json_encode(["error" => "Password must be at least 6 characters."]); exit; } $result = UserModel::addUser($newUsername, $newPassword, $isAdmin, $setupMode); echo json_encode($result); exit; } public function removeUser() { self::jsonHeaders(); // Accept DELETE or POST for broader compatibility self::requireMethod(['DELETE', 'POST']); self::requireAdmin(); self::requireCsrf(); $data = self::readJson(); $usernameToRemove = trim($data['username'] ?? ''); if ($usernameToRemove === '') { echo json_encode(["error" => "Username is required"]); exit; } if (!preg_match(REGEX_USER, $usernameToRemove)) { echo json_encode(["error" => "Invalid username format"]); exit; } if (!empty($_SESSION['username']) && $_SESSION['username'] === $usernameToRemove) { echo json_encode(["error" => "Cannot remove yourself"]); exit; } $result = UserModel::removeUser($usernameToRemove); echo json_encode($result); exit; } public function getUserPermissions() { self::jsonHeaders(); self::requireAuth(); $permissions = UserModel::getUserPermissions(); echo json_encode($permissions); exit; } public function updateUserPermissions() { self::jsonHeaders(); // Accept PUT or POST for compatibility with clients that can't send PUT self::requireMethod(['PUT', 'POST']); self::requireAdmin(); self::requireCsrf(); $input = self::readJson(); if (!isset($input['permissions']) || !is_array($input['permissions'])) { echo json_encode(["error" => "Invalid input"]); exit; } $permissions = $input['permissions']; $result = UserModel::updateUserPermissions($permissions); echo json_encode($result); exit; } public function changePassword() { self::jsonHeaders(); self::requireMethod(['POST']); self::requireAuth(); self::requireCsrf(); $username = $_SESSION['username'] ?? ''; if ($username === '') { echo json_encode(["error" => "No username in session"]); exit; } $data = self::readJson(); $oldPassword = trim($data["oldPassword"] ?? ""); $newPassword = trim($data["newPassword"] ?? ""); $confirmPassword = trim($data["confirmPassword"] ?? ""); if ($oldPassword === '' || $newPassword === '' || $confirmPassword === '') { echo json_encode(["error" => "All fields are required."]); exit; } if ($newPassword !== $confirmPassword) { echo json_encode(["error" => "New passwords do not match."]); exit; } if (strlen($newPassword) < 6) { echo json_encode(["error" => "Password must be at least 6 characters."]); exit; } $result = UserModel::changePassword($username, $oldPassword, $newPassword); echo json_encode($result); exit; } public function updateUserPanel() { self::jsonHeaders(); // Accept PUT or POST for compatibility self::requireMethod(['PUT', 'POST']); self::requireAuth(); self::requireCsrf(); $data = self::readJson(); if (!is_array($data)) { http_response_code(400); echo json_encode(["error" => "Invalid input"]); exit; } $username = $_SESSION['username'] ?? ''; if ($username === '') { http_response_code(400); echo json_encode(["error" => "No username in session"]); exit; } $totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false; $result = UserModel::updateUserPanel($username, $totp_enabled); echo json_encode($result); exit; } public function disableTOTP() { self::jsonHeaders(); // Accept PUT or POST self::requireMethod(['PUT', 'POST']); self::requireAuth(); self::requireCsrf(); $username = $_SESSION['username'] ?? ''; if ($username === '') { http_response_code(400); echo json_encode(["error" => "Username not found in session"]); exit; } $result = UserModel::disableTOTPSecret($username); if ($result) { echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]); } else { http_response_code(500); echo json_encode(["error" => "Failed to disable TOTP."]); } exit; } public function recoverTOTP() { self::jsonHeaders(); self::requireMethod(['POST']); self::requireCsrf(); $userId = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? null); if (!$userId) { http_response_code(401); echo json_encode(['status' => 'error', 'message' => 'Unauthorized']); exit; } if (!preg_match(REGEX_USER, $userId)) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']); exit; } $inputData = self::readJson(); $recoveryCode = $inputData['recovery_code'] ?? ''; $result = UserModel::recoverTOTP($userId, $recoveryCode); if (($result['status'] ?? '') === 'ok') { // Finalize login session_regenerate_id(true); $_SESSION['authenticated'] = true; $_SESSION['username'] = $userId; unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']); echo json_encode(['status' => 'ok']); } else { if (($result['message'] ?? '') === 'Too many attempts. Try again later.') { http_response_code(429); } else { http_response_code(400); } echo json_encode($result); } exit; } public function saveTOTPRecoveryCode() { self::jsonHeaders(); self::requireMethod(['POST']); self::requireCsrf(); if (empty($_SESSION['username'])) { http_response_code(401); echo json_encode(['status' => 'error', 'message' => 'Unauthorized']); exit; } $userId = $_SESSION['username']; if (!preg_match(REGEX_USER, $userId)) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']); exit; } $result = UserModel::saveTOTPRecoveryCode($userId); if (($result['status'] ?? '') === 'ok') { echo json_encode($result); } else { http_response_code(500); echo json_encode($result); } exit; } public function setupTOTP() { // Allow access if authenticated OR pending TOTP if (!( (isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true) || isset($_SESSION['pending_login_user']) )) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error" => "Not authorized to access TOTP setup"]); exit; } self::requireCsrf(); // Fix: if username not present (pending flow), fall back to pending_login_user $username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? ''); if ($username === '') { http_response_code(400); header('Content-Type: application/json'); echo json_encode(['error' => 'Username not available for TOTP setup']); exit; } header("Content-Type: image/png"); header('X-Content-Type-Options: nosniff'); $result = UserModel::setupTOTP($username); if (isset($result['error'])) { http_response_code(500); header('Content-Type: application/json'); echo json_encode(["error" => $result['error']]); exit; } echo $result['imageData']; exit; } public function verifyTOTP() { header('Content-Type: application/json'); header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';"); header('X-Content-Type-Options: nosniff'); // Rate-limit if (!isset($_SESSION['totp_failures'])) { $_SESSION['totp_failures'] = 0; } if ($_SESSION['totp_failures'] >= 5) { http_response_code(429); echo json_encode(['status' => 'error', 'message' => 'Too many TOTP attempts. Please try again later.']); exit; } // Must be authenticated OR pending login if (empty($_SESSION['authenticated']) && !isset($_SESSION['pending_login_user'])) { http_response_code(403); echo json_encode(['status' => 'error', 'message' => 'Not authenticated']); exit; } // CSRF check self::requireCsrf(); // Parse & validate input $inputData = self::readJson(); $code = trim($inputData['totp_code'] ?? ''); if (!preg_match('/^\d{6}$/', $code)) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'A valid 6-digit TOTP code is required']); exit; } // TFA helper $tfa = new \RobThree\Auth\TwoFactorAuth( new \RobThree\Auth\Providers\Qr\GoogleChartsQrCodeProvider(), 'FileRise', 6, 30, \RobThree\Auth\Algorithm::Sha1 ); // Pending-login flow if (isset($_SESSION['pending_login_user'])) { $username = $_SESSION['pending_login_user']; $pendingSecret = $_SESSION['pending_login_secret'] ?? null; $rememberMe = $_SESSION['pending_login_remember_me'] ?? false; if (!$pendingSecret || !$tfa->verifyCode($pendingSecret, $code)) { $_SESSION['totp_failures']++; http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); exit; } // Issue “remember me” token if requested if ($rememberMe) { $tokFile = USERS_DIR . 'persistent_tokens.json'; $token = bin2hex(random_bytes(32)); $expiry = time() + 30 * 24 * 60 * 60; $all = []; if (file_exists($tokFile)) { $dec = decryptData(file_get_contents($tokFile), $GLOBALS['encryptionKey']); $all = json_decode($dec, true) ?: []; } $perms = loadUserPermissions($username); $all[$token] = [ 'username' => $username, 'expiry' => $expiry, 'isAdmin' => ((int)UserModel::getUserRole($username) === 1), 'folderOnly' => $perms['folderOnly'] ?? false, 'readOnly' => $perms['readOnly'] ?? false, 'disableUpload' => $perms['disableUpload'] ?? false ]; file_put_contents( $tokFile, encryptData(json_encode($all, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']), LOCK_EX ); $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); setcookie('remember_me_token', $token, $expiry, '/', '', $secure, true); setcookie(session_name(), session_id(), $expiry, '/', '', $secure, true); } // Finalize login session_regenerate_id(true); $_SESSION['authenticated'] = true; $_SESSION['username'] = $username; $_SESSION['isAdmin'] = ((int)UserModel::getUserRole($username) === 1); $perms = loadUserPermissions($username); $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; $_SESSION['readOnly'] = $perms['readOnly'] ?? false; $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; unset( $_SESSION['pending_login_user'], $_SESSION['pending_login_secret'], $_SESSION['pending_login_remember_me'], $_SESSION['totp_failures'] ); echo json_encode([ 'status' => 'ok', 'success' => 'Login successful', 'isAdmin' => $_SESSION['isAdmin'], 'folderOnly' => $_SESSION['folderOnly'], 'readOnly' => $_SESSION['readOnly'], 'disableUpload' => $_SESSION['disableUpload'], 'username' => $_SESSION['username'] ]); exit; } // Setup/verification flow (not pending) $username = $_SESSION['username'] ?? ''; if ($username === '') { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Username not found in session']); exit; } $totpSecret = UserModel::getTOTPSecret($username); if (!$totpSecret) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'TOTP secret not found. Please set up TOTP again.']); exit; } if (!$tfa->verifyCode($totpSecret, $code)) { $_SESSION['totp_failures']++; http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'Invalid TOTP code']); exit; } unset($_SESSION['totp_failures']); echo json_encode(['status' => 'ok', 'message' => 'TOTP successfully verified']); exit; } /** * Upload profile picture (multipart/form-data) */ public function uploadPicture() { self::jsonHeaders(); // Auth & CSRF self::requireAuth(); self::requireCsrf(); if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'No file uploaded or error']); exit; } $file = $_FILES['profile_picture']; // Validate MIME & size $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!isset($allowed[$mime])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid file type']); exit; } if ($file['size'] > 2 * 1024 * 1024) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'File too large']); exit; } // Destination $uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics'; if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']); exit; } $ext = $allowed[$mime]; $user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username']); $filename = $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext; $dest = $uploadDir . '/' . $filename; if (!move_uploaded_file($file['tmp_name'], $dest)) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Failed to save file']); exit; } // Assuming /uploads maps to UPLOAD_DIR publicly $url = '/uploads/profile_pics/' . $filename; $result = UserModel::setProfilePicture($_SESSION['username'], $url); if (!($result['success'] ?? false)) { @unlink($dest); http_response_code(500); echo json_encode([ 'success' => false, 'error' => 'Failed to save profile picture setting' ]); exit; } echo json_encode(['success' => true, 'url' => $url]); exit; } public function siteConfig(): void { header('Content-Type: application/json'); $usersDir = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR; $publicPath = $usersDir . 'siteConfig.json'; $adminEncPath = $usersDir . 'adminConfig.json'; $publicMtime = is_file($publicPath) ? (int)@filemtime($publicPath) : 0; $adminMtime = is_file($adminEncPath) ? (int)@filemtime($adminEncPath) : 0; // If public cache is present and fresh enough, serve it if ($publicMtime > 0 && $publicMtime >= $adminMtime) { $raw = @file_get_contents($publicPath); $data = is_string($raw) ? json_decode($raw, true) : null; if (is_array($data)) { echo json_encode($data); return; } } // Otherwise regenerate from decrypted admin config $cfg = AdminModel::getConfig(); if (isset($cfg['error'])) { http_response_code(500); echo json_encode(['error' => $cfg['error']]); return; } $public = AdminModel::buildPublicSubset($cfg); $w = AdminModel::writeSiteConfig($public); // best effort echo json_encode($public); } }