diff --git a/CHANGELOG.md b/CHANGELOG.md index ed62b40..5803fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ This refactor improves maintainability, testability, and documentation clarity a - Switched your share modal code to use a leading slash ("/api/file/share.php") so it generates absolute URLs instead of relative /share.php. - In the shared‑folder gallery, adjusted the client‑side image path to point at /uploads/... instead of /api/folder/uploads/... - Updated both AdminModel defaults and the AuthController to use the exact full path +- Network Utilities Overhaul swapped out the old fetch wrapper for one that always reads the raw response, tries to JSON.parse it, and then either returns the parsed object on ok or throws it on error. +- Adjusted your submitLogin .catch() to grab the thrown object (or string) and pass that through to showToast, so now “Invalid credentials” actually shows up. +- Pulled the common session‑setup and “remember me” logic into two new helpers, finalizeLogin() (for AJAX/form/basic/TOTP) and finishBrowserLogin() (for OIDC redirects). That removed tons of duplication and ensures every path calls the same permission‑loading code. +- Ensured that after you POST just a totp_code, we pick up pending_login_user/pending_login_secret, verify it, then immediately call finalizeLogin(). +- Expanded checkAuth.php Response now returns all three flags—folderOnly, readOnly, and disableUpload so client can handle every permission. +- In auth.js’s updateAuthenticatedUI(), write all three flags into localStorage whenever you land on the app (OIDC, basic or form). That guarantees consistent behavior across page loads. +- Made sure the OIDC handler reads the live config via AdminModel::getConfig() and pushes you through the TOTP flow if needed, then back to /index.html. --- diff --git a/config/config.php b/config/config.php index 3a94c9d..060b585 100644 --- a/config/config.php +++ b/config/config.php @@ -118,10 +118,10 @@ $cookieParams = [ 'samesite' => 'Lax' ]; // At the very beginning of config.php -ini_set('session.save_path', __DIR__ . '/../sessions'); +/*ini_set('session.save_path', __DIR__ . '/../sessions'); if (!is_dir(__DIR__ . '/../sessions')) { mkdir(__DIR__ . '/../sessions', 0777, true); -} +}*/ if (session_status() === PHP_SESSION_NONE) { session_set_cookie_params($cookieParams); ini_set('session.gc_maxlifetime', 7200); @@ -164,7 +164,7 @@ define('BASE_URL', 'http://yourwebsite/uploads/'); if (strpos(BASE_URL, 'yourwebsite') !== false) { $defaultShareUrl = isset($_SERVER['HTTP_HOST']) - ? "http://" . $_SERVER['HTTP_HOST'] . "/share.php" + ? "http://" . $_SERVER['HTTP_HOST'] . "/api/file/share.php" : "http://localhost/api/file/share.php"; } else { $defaultShareUrl = rtrim(BASE_URL, '/') . "/api/file/share.php"; diff --git a/public/index.html b/public/index.html index 3fba26a..9cc14b3 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,7 @@ - + diff --git a/public/js/auth.js b/public/js/auth.js index 0dc3183..3ece6b5 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -149,11 +149,12 @@ function updateAuthenticatedUI(data) { if (data.username) { localStorage.setItem("username", data.username); } - /* - if (typeof data.folderOnly !== "undefined") { - localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); + if (typeof data.folderOnly !== "undefined") { + localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); + localStorage.setItem("readOnly", data.readOnly ? "true" : "false"); + localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false"); } -*/ + const headerButtons = document.querySelector(".header-buttons"); const firstButton = headerButtons.firstElementChild; @@ -227,6 +228,10 @@ function checkAuthentication(showLoginToast = true) { } window.setupMode = false; if (data.authenticated) { + localStorage.setItem("folderOnly", data.folderOnly ); + localStorage.setItem("readOnly", data.readOnly ); + localStorage.setItem("disableUpload",data.disableUpload); + updateLoginOptionsUIFromStorage(); if (typeof data.totp_enabled !== "undefined") { localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); } @@ -249,6 +254,7 @@ function checkAuthentication(showLoginToast = true) { function submitLogin(data) { setLastLoginData(data); window.__lastLoginData = data; + sendRequest("api/auth/auth.php", "POST", data, { "X-CSRF-Token": window.csrfToken }) .then(response => { if (response.success || response.status === "ok") { @@ -263,7 +269,7 @@ function submitLogin(data) { } }) .catch(() => { - // if fetching permissions fails. + // ignore permission‐fetch errors }) .finally(() => { window.location.reload(); @@ -272,7 +278,7 @@ function submitLogin(data) { openTOTPLoginModal(); } else if (response.error && response.error.includes("Too many failed login attempts")) { showToast(response.error); - const loginButton = document.getElementById("authForm").querySelector("button[type='submit']"); + const loginButton = document.querySelector("#authForm button[type='submit']"); if (loginButton) { loginButton.disabled = true; setTimeout(() => { @@ -284,10 +290,18 @@ function submitLogin(data) { showToast("Login failed: " + (response.error || "Unknown error")); } }) - .catch(() => { - showToast("Login failed: Unknown error"); + .catch(err => { + // err may be an Error object or a string + let msg = "Unknown error"; + if (err && typeof err === "object") { + msg = err.error || err.message || msg; + } else if (typeof err === "string") { + msg = err; + } + showToast(`Login failed: ${msg}`); }); } + window.submitLogin = submitLogin; /* ----------------- Other Helpers ----------------- */ diff --git a/public/js/networkUtils.js b/public/js/networkUtils.js index 6ecd10d..72911aa 100644 --- a/public/js/networkUtils.js +++ b/public/js/networkUtils.js @@ -1,31 +1,31 @@ +// public/js/networkUtils.js export function sendRequest(url, method = "GET", data = null, customHeaders = {}) { const options = { method, credentials: 'include', - headers: {} + headers: { ...customHeaders } }; - // Merge custom headers - Object.assign(options.headers, customHeaders); - - // If data is provided and is not FormData, assume JSON. if (data && !(data instanceof FormData)) { - if (!options.headers["Content-Type"]) { - options.headers["Content-Type"] = "application/json"; - } + options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json'; options.body = JSON.stringify(data); } else if (data instanceof FormData) { options.body = data; } return fetch(url, options) - .then(response => { - if (!response.ok) { - return response.text().then(text => { - throw new Error(`HTTP error ${response.status}: ${text}`); - }); + .then(async res => { + const text = await res.text(); + let payload; + try { + payload = JSON.parse(text); + } catch { + payload = text; } - const clonedResponse = response.clone(); - return response.json().catch(() => clonedResponse.text()); + if (!res.ok) { + // Reject with the parsed JSON (or raw text) so .catch(error) gets it + throw payload; + } + return payload; }); } \ No newline at end of file diff --git a/src/controllers/authController.php b/src/controllers/authController.php index dfb93d2..4c56686 100644 --- a/src/controllers/authController.php +++ b/src/controllers/authController.php @@ -58,209 +58,233 @@ class AuthController * * @return void Redirects on success or outputs JSON error. */ + // in src/controllers/AuthController.php + public function auth(): void { - // Global exception handler. + header('Content-Type: application/json'); set_exception_handler(function ($e) { error_log("Unhandled exception: " . $e->getMessage()); http_response_code(500); - echo json_encode(["error" => "Internal Server Error"]); + echo json_encode(['error' => 'Internal Server Error']); exit(); }); - header('Content-Type: application/json'); + // Decode any JSON payload + $data = json_decode(file_get_contents('php://input'), true) ?: []; + $username = trim($data['username'] ?? ''); + $password = trim($data['password'] ?? ''); + $totpCode = trim($data['totp_code'] ?? ''); + $rememberMe = !empty($data['remember_me']); - // If OIDC parameters are present, initiate OIDC flow. + // + // 1) TOTP‑only step: user already passed credentials and we asked for TOTP, + // now they POST just totp_code. + // + if ($totpCode && isset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret'])) { + $username = $_SESSION['pending_login_user']; + $secret = $_SESSION['pending_login_secret']; + + $tfa = new TwoFactorAuth(new GoogleChartsQrCodeProvider(), 'FileRise', 6, 30, Algorithm::Sha1); + if (! $tfa->verifyCode($secret, $totpCode)) { + echo json_encode(['error' => 'Invalid TOTP code']); + exit(); + } + // clear the pending markers + unset($_SESSION['pending_login_user'], $_SESSION['pending_login_secret']); + // now finish login + $this->finalizeLogin($username, $rememberMe); + } + + // + // 2) OIDC flow + // $oidcAction = $_GET['oidc'] ?? null; - if (!$oidcAction && isset($_GET['code'])) { + if (! $oidcAction && isset($_GET['code'])) { $oidcAction = 'callback'; } if ($oidcAction) { - // new: delegate to AdminModel $cfg = AdminModel::getConfig(); - // Optional: log to confirm you loaded the right values - error_log("Loaded OIDC config: " . print_r($cfg['oidc'], true)); - - $oidc_provider_url = $cfg['oidc']['providerUrl']; - $oidc_client_id = $cfg['oidc']['clientId']; - $oidc_client_secret = $cfg['oidc']['clientSecret']; - $oidc_redirect_uri = $cfg['oidc']['redirectUri']; - $oidc = new OpenIDConnectClient($oidc_provider_url, $oidc_client_id, $oidc_client_secret); - $oidc->setRedirectURL($oidc_redirect_uri); + $oidc = new OpenIDConnectClient( + $cfg['oidc']['providerUrl'], + $cfg['oidc']['clientId'], + $cfg['oidc']['clientSecret'] + ); + $oidc->setRedirectURL($cfg['oidc']['redirectUri']); if ($oidcAction === 'callback') { try { $oidc->authenticate(); $username = $oidc->requestUserInfo('preferred_username'); - // Check for TOTP secret. + // check if this user has a TOTP secret $totp_secret = null; $usersFile = USERS_DIR . USERS_FILE; if (file_exists($usersFile)) { foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - $parts = explode(":", trim($line)); - if (count($parts) >= 4 && $parts[0] === $username && !empty($parts[3])) { - $totp_secret = decryptData($parts[3], $encryptionKey); + $parts = explode(':', trim($line)); + if (count($parts) >= 4 && $parts[0] === $username && $parts[3] !== '') { + $totp_secret = decryptData($parts[3], $GLOBALS['encryptionKey']); break; } } } if ($totp_secret) { - $_SESSION['pending_login_user'] = $username; + $_SESSION['pending_login_user'] = $username; $_SESSION['pending_login_secret'] = $totp_secret; - header("Location: /index.html?totp_required=1"); + header('Location: /index.html?totp_required=1'); exit(); } - // Finalize login (no TOTP) - session_regenerate_id(true); - $_SESSION["authenticated"] = true; - $_SESSION["username"] = $username; - $_SESSION["isAdmin"] = (AuthModel::getUserRole($username) === "1"); - $_SESSION["folderOnly"] = loadUserPermissions($username); - header("Location: /index.html"); - exit(); - } catch (Exception $e) { - error_log("OIDC authentication error: " . $e->getMessage()); + // no TOTP → finish immediately + $this->finishBrowserLogin($username); + } catch (\Exception $e) { + error_log("OIDC auth error: " . $e->getMessage()); http_response_code(401); - echo json_encode(["error" => "Authentication failed."]); + echo json_encode(['error' => 'Authentication failed.']); exit(); } } else { - // Initiate OIDC authentication. + // initial OIDC redirect try { $oidc->authenticate(); exit(); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("OIDC initiation error: " . $e->getMessage()); http_response_code(401); - echo json_encode(["error" => "Authentication initiation failed."]); + echo json_encode(['error' => 'Authentication initiation failed.']); exit(); } } } - // Fallback: Form-based Authentication. - $data = json_decode(file_get_contents("php://input"), true); - $username = trim($data["username"] ?? ""); - $password = trim($data["password"] ?? ""); - $rememberMe = isset($data["remember_me"]) && $data["remember_me"] === true; - - if (!$username || !$password) { + // + // 3) Form‑based / AJAX login + // + if (! $username || ! $password) { http_response_code(400); - echo json_encode(["error" => "Username and password are required"]); + echo json_encode(['error' => 'Username and password are required']); + exit(); + } + if (! preg_match(REGEX_USER, $username)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid username format']); exit(); } - if (!preg_match(REGEX_USER, $username)) { - http_response_code(400); - echo json_encode(["error" => "Invalid username format. Only letters, numbers, underscores, dashes, and spaces are allowed."]); - exit(); - } - - $ip = $_SERVER['REMOTE_ADDR']; - $currentTime = time(); + // rate‑limit + $ip = $_SERVER['REMOTE_ADDR']; $attemptsFile = USERS_DIR . 'failed_logins.json'; - $failedAttempts = AuthModel::loadFailedAttempts($attemptsFile); - $maxAttempts = 5; - $lockoutTime = 30 * 60; // 30 minutes - - if (isset($failedAttempts[$ip])) { - $attemptData = $failedAttempts[$ip]; - if ($attemptData['count'] >= $maxAttempts && ($currentTime - $attemptData['last_attempt']) < $lockoutTime) { - http_response_code(429); - echo json_encode(["error" => "Too many failed login attempts. Please try again later."]); - exit(); - } + $failed = AuthModel::loadFailedAttempts($attemptsFile); + if ( + isset($failed[$ip]) && + $failed[$ip]['count'] >= 5 && + time() - $failed[$ip]['last_attempt'] < 30 * 60 + ) { + http_response_code(429); + echo json_encode(['error' => 'Too many failed login attempts. Please try again later.']); + exit(); } $user = AuthModel::authenticate($username, $password); - if ($user !== false) { - // Handle TOTP if required. - if (!empty($user['totp_secret'])) { - if (empty($data['totp_code']) || !preg_match('/^\d{6}$/', $data['totp_code'])) { - $_SESSION['pending_login_user'] = $username; - $_SESSION['pending_login_secret'] = $user['totp_secret']; - echo json_encode([ - "totp_required" => true, - "message" => "TOTP code required" - ]); - exit(); - } else { - $tfa = new \RobThree\Auth\TwoFactorAuth( - new GoogleChartsQrCodeProvider(), - 'FileRise', - 6, - 30, - Algorithm::Sha1 - ); - $providedCode = trim($data['totp_code']); - if (!$tfa->verifyCode($user['totp_secret'], $providedCode)) { - echo json_encode(["error" => "Invalid TOTP code"]); - exit(); - } - } - } - - // Clear failed attempts. - if (isset($failedAttempts[$ip])) { - unset($failedAttempts[$ip]); - AuthModel::saveFailedAttempts($attemptsFile, $failedAttempts); - } - - session_regenerate_id(true); - $_SESSION["authenticated"] = true; - $_SESSION["username"] = $username; - $_SESSION["isAdmin"] = ($user['role'] === "1"); - $_SESSION["folderOnly"] = loadUserPermissions($username); - - // Handle "remember me" - if ($rememberMe) { - $persistentTokensFile = USERS_DIR . 'persistent_tokens.json'; - $tokenPersistent = bin2hex(random_bytes(32)); - $expiry = time() + (30 * 24 * 60 * 60); - $persistentTokens = []; - if (file_exists($persistentTokensFile)) { - $encryptedContent = file_get_contents($persistentTokensFile); - $decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']); - $persistentTokens = json_decode($decryptedContent, true); - if (!is_array($persistentTokens)) { - $persistentTokens = []; - } - } - $persistentTokens[$tokenPersistent] = [ - "username" => $username, - "expiry" => $expiry, - "isAdmin" => ($_SESSION["isAdmin"] === true) - ]; - $encryptedContent = encryptData(json_encode($persistentTokens, JSON_PRETTY_PRINT), $GLOBALS['encryptionKey']); - file_put_contents($persistentTokensFile, $encryptedContent, LOCK_EX); - $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); - setcookie('remember_me_token', $tokenPersistent, $expiry, '/', '', $secure, true); - } - - echo json_encode([ - "status" => "ok", - "success" => "Login successful", - "isAdmin" => $_SESSION["isAdmin"], - "folderOnly" => $_SESSION["folderOnly"], - "username" => $_SESSION["username"] - ]); - } else { - // Record failed login attempt. - if (isset($failedAttempts[$ip])) { - $failedAttempts[$ip]['count']++; - $failedAttempts[$ip]['last_attempt'] = $currentTime; - } else { - $failedAttempts[$ip] = ['count' => 1, 'last_attempt' => $currentTime]; - } - AuthModel::saveFailedAttempts($attemptsFile, $failedAttempts); - $failedLogFile = USERS_DIR . 'failed_login.log'; - $logLine = date('Y-m-d H:i:s') . " - Failed login attempt for username: " . $username . " from IP: " . $ip . PHP_EOL; - file_put_contents($failedLogFile, $logLine, FILE_APPEND); + if ($user === false) { + // record failure + $failed[$ip] = [ + 'count' => ($failed[$ip]['count'] ?? 0) + 1, + 'last_attempt' => time() + ]; + AuthModel::saveFailedAttempts($attemptsFile, $failed); http_response_code(401); - echo json_encode(["error" => "Invalid credentials"]); + echo json_encode(['error' => 'Invalid credentials']); + exit(); } + + // if this account has TOTP, ask for it + if (! empty($user['totp_secret'])) { + $_SESSION['pending_login_user'] = $username; + $_SESSION['pending_login_secret'] = $user['totp_secret']; + echo json_encode(['totp_required' => true]); + exit(); + } + + // otherwise clear rate‑limit & finish + if (isset($failed[$ip])) { + unset($failed[$ip]); + AuthModel::saveFailedAttempts($attemptsFile, $failed); + } + $this->finalizeLogin($username, $rememberMe); + } + + /** + * Finalize an AJAX‐style login (form/basic/TOTP) by + * issuing the session, remember‑me cookie, and JSON payload. + */ + protected function finalizeLogin(string $username, bool $rememberMe): void + { + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1'); + + $perms = loadUserPermissions($username); + $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; + $_SESSION['readOnly'] = $perms['readOnly'] ?? false; + $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; + + // remember‑me + 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) ?: []; + } + $all[$token] = [ + 'username' => $username, + 'expiry' => $expiry, + 'isAdmin' => $_SESSION['isAdmin'] + ]; + 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); + } + + echo json_encode([ + 'status' => 'ok', + 'success' => 'Login successful', + 'isAdmin' => $_SESSION['isAdmin'], + 'folderOnly' => $_SESSION['folderOnly'], + 'readOnly' => $_SESSION['readOnly'], + 'disableUpload' => $_SESSION['disableUpload'], + 'username' => $username + ]); + exit(); + } + + /** + * A version of finalizeLogin() that ends in a browser redirect + * (used for OIDC non‑AJAX flows). + */ + protected function finishBrowserLogin(string $username): void + { + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['username'] = $username; + $_SESSION['isAdmin'] = (AuthModel::getUserRole($username) === '1'); + + $perms = loadUserPermissions($username); + $_SESSION['folderOnly'] = $perms['folderOnly'] ?? false; + $_SESSION['readOnly'] = $perms['readOnly'] ?? false; + $_SESSION['disableUpload'] = $perms['disableUpload'] ?? false; + + header('Location: /index.html'); + exit(); } /** @@ -296,51 +320,45 @@ class AuthController * * @return void Outputs a JSON response with authentication details. */ + public function checkAuth(): void { header('Content-Type: application/json'); - $usersFile = USERS_DIR . USERS_FILE; - // If the users file does not exist or is empty, signal setup mode. + + // setup mode? if (!file_exists($usersFile) || trim(file_get_contents($usersFile)) === '') { - error_log("checkAuth: users file not found or empty; entering setup mode."); - echo json_encode(["setup" => true]); - exit; + error_log("checkAuth: setup mode"); + echo json_encode(['setup' => true]); + exit(); + } + if (empty($_SESSION['authenticated'])) { + echo json_encode(['authenticated' => false]); + exit(); } - // If the session is not authenticated, output false. - if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { - echo json_encode(["authenticated" => false]); - exit; - } - - // Retrieve the username from the session. - $username = $_SESSION['username'] ?? ''; - // Determine TOTP enabled by checking the users file. - $totp_enabled = false; - if ($username) { - foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - $parts = explode(':', trim($line)); - if ($parts[0] === $username && isset($parts[3]) && trim($parts[3]) !== "") { - $totp_enabled = true; - break; - } + // TOTP enabled? + $totp = false; + foreach (file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $parts = explode(':', trim($line)); + if ($parts[0] === $_SESSION['username'] && !empty($parts[3])) { + $totp = true; + break; } } - // Determine admin status using AuthModel::getUserRole() - $userRole = AuthModel::getUserRole($username); - $isAdmin = ((int)$userRole === 1); - - $response = [ - "authenticated" => true, - "isAdmin" => $isAdmin, - "totp_enabled" => $totp_enabled, - "username" => $username, - "folderOnly" => $_SESSION["folderOnly"] ?? false + $isAdmin = ((int)AuthModel::getUserRole($_SESSION['username']) === 1); + $resp = [ + 'authenticated' => true, + 'isAdmin' => $isAdmin, + 'totp_enabled' => $totp, + 'username' => $_SESSION['username'], + 'folderOnly' => $_SESSION['folderOnly'] ?? false, + 'readOnly' => $_SESSION['readOnly'] ?? false, + 'disableUpload' => $_SESSION['disableUpload'] ?? false ]; - echo json_encode($response); - exit; + echo json_encode($resp); + exit(); } /** @@ -441,7 +459,11 @@ class AuthController $_SESSION["authenticated"] = true; $_SESSION["username"] = $username; $_SESSION["isAdmin"] = (AuthModel::getUserRole($username) === "1"); - $_SESSION["folderOnly"] = AuthModel::loadFolderPermission($username); + // load _all_ the permissions + $userPerms = loadUserPermissions($username); + $_SESSION["folderOnly"] = $userPerms["folderOnly"] ?? false; + $_SESSION["readOnly"] = $userPerms["readOnly"] ?? false; + $_SESSION["disableUpload"] = $userPerms["disableUpload"] ?? false; header("Location: /index.html"); exit; diff --git a/src/controllers/fileController.php b/src/controllers/fileController.php index 649cc12..cdebfc8 100644 --- a/src/controllers/fileController.php +++ b/src/controllers/fileController.php @@ -1301,7 +1301,18 @@ class FileController { // Delegate deletion to the model. $result = FileModel::deleteTrashFiles($filesToDelete); - echo json_encode($result); + + // Build a human‑friendly success or error message + if (!empty($result['deleted'])) { + $count = count($result['deleted']); + $msg = "Trash item" . ($count===1 ? "" : "s") . " deleted: " . implode(", ", $result['deleted']); + echo json_encode(["success" => $msg]); + } elseif (!empty($result['error'])) { + echo json_encode(["error" => $result['error']]); + } else { + echo json_encode(["success" => "No items to delete."]); + } + exit; } /**