<?php
// FIRST LINE OF DEFENCE — pin open_basedir before any other PHP code in the
// request runs. Loading this here covers every HTTP request through the
// panel; CLI workers pick it up indirectly via configs/config.php (which
// require_once's the same file).
require_once('/docker/whp/web/includes/safety-net.php');

// Security response headers — applied to every request handled by this prepend.
// Hide PHP version disclosure and apply hardening headers for browsers. CSP
// intentionally omitted here; it needs nonce wiring for the inline scripts the
// panel uses and will land in a follow-up.
header_remove('X-Powered-By');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: same-origin');
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
if (!empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') {
    header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}

// Pages under pages/ and partials under includes/ are include()d by index.php
// only — never legitimate direct HTTP targets. A direct request would pass
// authentication below but SKIP check_page_permission(), which is gated on
// SCRIPT_FILENAME == index.php. Deny here (and via pages/.htaccess) so the
// per-page permission whitelist cannot be bypassed.
if (PHP_SAPI !== 'cli') {
    $direct_script = $_SERVER['SCRIPT_FILENAME'] ?? '';
    if (strpos($direct_script, '/docker/whp/web/pages/') === 0
        || strpos($direct_script, '/docker/whp/web/includes/') === 0) {
        http_response_code(403);
        exit('Forbidden');
    }
}

require("/docker/whp/web/libs/security.php");
require('/docker/whp/web/configs/config.php');
require('/docker/whp/web/libs/network.php');
require('/docker/whp/web/libs/security_manager.php');
require('/docker/whp/web/libs/permission_checker.php');
require('/docker/whp/web/libs/delegated_user_manager.php');
require('/docker/whp/web/libs/time_helpers.php');

// Function to check if current request is to an API endpoint
function is_api_endpoint() {
    $script_path = $_SERVER['SCRIPT_FILENAME'];
    $request_uri = $_SERVER['REQUEST_URI'];

    // Check if the script is in the API directory
    if (strpos($script_path, '/docker/whp/web/api/') !== false) {
        return true;
    }

    // Check if the request URI starts with /api/
    if (strpos($request_uri, '/api/') === 0) {
        return true;
    }

    return false;
}

// Function to check if current request is to the health check endpoint (no auth required)
function is_health_check_endpoint() {
    $script_path = $_SERVER['SCRIPT_FILENAME'];
    $request_uri = $_SERVER['REQUEST_URI'];

    // Check if the script is health.php
    if (strpos($script_path, '/api/health.php') !== false) {
        return true;
    }

    // Check if the request URI is for health check
    if (strpos($request_uri, '/api/health.php') !== false) {
        return true;
    }

    return false;
}

// Function to check if current request is to Adminer
function is_adminer_endpoint() {
    $script_path = $_SERVER['SCRIPT_FILENAME'];
    $request_uri = $_SERVER['REQUEST_URI'];
    $script_name = basename($_SERVER['SCRIPT_NAME']);
    
    // Check if the script is adminer.php
    if ($script_name === 'adminer.php') {
        return true;
    }
    
    // Check if the request URI is for adminer
    if (strpos($request_uri, '/adminer.php') !== false) {
        return true;
    }
    
    // Check if the script path contains adminer.php
    if (strpos($script_path, 'adminer.php') !== false) {
        return true;
    }
    
    return false;
}

// Function to check if current request is to filemanager
function is_filemanager_endpoint() {
    $script_path = $_SERVER['SCRIPT_FILENAME'];
    $request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    
    // Check if the script is in the filemanager directory
    if (strpos($script_path, '/docker/whp/web/filemanager/') === 0) {
        return true;
    }
    
    // Check if the request URI starts with /filemanager/ (exact match)
    if (strpos($request_uri, '/filemanager/') === 0) {
        return true;
    }
    
    return false;
}

// Function to validate CSRF token
function validate_csrf_token() {
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $csrf_token = null;
        
        // Check for CSRF token in POST data first
        if (!empty($_POST['csrf_token'])) {
            $csrf_token = $_POST['csrf_token'];
        }
        // For API endpoints, check X-CSRF-Token header
        elseif (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
            $csrf_token = $_SERVER['HTTP_X_CSRF_TOKEN'];
        }
        
        if (empty($_SESSION['csrf_token']) || empty($csrf_token)) {
            return false;
        }
        
        // Use hash_equals to prevent timing attacks
        return hash_equals($_SESSION['csrf_token'], $csrf_token);
    }
    return true; // GET requests don't need CSRF validation
}

// Function to get CSRF token for forms
function get_csrf_token() {
    return isset($_SESSION['csrf_token']) ? $_SESSION['csrf_token'] : '';
}

// Skip all authentication and security checks for health check endpoint
if (is_health_check_endpoint()) {
    // Health check endpoint requires no authentication
    return;
}

// "Stay logged in" hint — when the user previously ticked the checkbox we set
// a long-lived `whp_persist` cookie alongside the session cookie. On every
// subsequent request, BEFORE session_start fires inside security_wrapper, we
// re-issue the session cookie with the same long lifetime so it survives
// browser restarts and PHP session-file GC. The cookie itself is HttpOnly+
// Secure (in HTTPS); JS can't read or forge it.
define('WHP_PERSIST_SECONDS', 7 * 86400);
// Even without Stay-Logged-In, a panel session needs to survive longer than
// PHP's stock 24-minute gc_maxlifetime — long-running flows (cpanel imports,
// WordPress installs, backup runs) routinely leave the page idle for an hour
// or more while a worker churns, and the next AJAX call lands AFTER the
// server-side session file has been GC'd. The page's JS-cached CSRF token
// then mismatches the fresh session's new token, and the AJAX gets rejected
// with what feels like a "stale token" error. 8h covers a normal working
// session; whp_persist bumps it to 7d as before.
define('WHP_PANEL_DEFAULT_SESSION_SECONDS', 8 * 3600);
$secureCookie = !empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
$panel_session_lifetime = !empty($_COOKIE['whp_persist'])
    ? WHP_PERSIST_SECONDS
    : WHP_PANEL_DEFAULT_SESSION_SECONDS;
ini_set('session.gc_maxlifetime', (string)$panel_session_lifetime);
session_set_cookie_params([
    'lifetime' => !empty($_COOKIE['whp_persist']) ? WHP_PERSIST_SECONDS : 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => $secureCookie,
    'httponly' => true,
    'samesite' => 'Lax',
]);

$SecMan = new security_wrapper();
$NetInfo = new network_info();
$SecManager = new security_manager();
$client_ip = $NetInfo->get_client_ip();
// Clean up expired blocks
$SecManager->cleanup_expired_blocks();

// Check if IP is blocked
$blocked_info = $SecManager->is_ip_blocked($client_ip);

if ($blocked_info) {
    // IP is blocked, show block page
    if ($_SERVER['SCRIPT_FILENAME'] != '/docker/whp/web/blocked.php') {
        header('Location: /blocked.php');
        exit();
    }
}
// Persist-token recovery — if the user has a real Stay-Logged-In cookie but
// no live session nonce (PHPSESSID expired, session file GC'd, or browser
// was closed before the PHPSESSID cookie lifetime fix landed), reconstruct
// the session from the cookie's selector:verifier pair. Multi-device safe:
// each device's token row is independent (no UNIQUE on user). Strict IP
// binding: roaming forces re-auth, same contract as nonces. Cookie is
// rotated on every use (single-use semantics defend against replay).
//
// Only fires for the selector:verifier cookie shape — legacy "whp_persist=1"
// values (and any garbage) fall through untouched. Delegated logins write
// the legacy "1" form intentionally (storing a token under the parent
// username and recovering as system would be a privilege escalation), so
// delegated users do NOT get token-based recovery in v1.
if ( empty($_SESSION['nonce'])
     && empty($_POST['user'])
     && !empty($_COOKIE['whp_persist'])
     && preg_match('/^[a-f0-9]{16}:[a-f0-9]{64}$/', $_COOKIE['whp_persist']) ) {

    $recovered = $SecMan->consume_persist_token($_COOKIE['whp_persist'], $client_ip);
    if ($recovered) {
        // Session-fixation defense — same as the login paths.
        session_regenerate_id(true);
        $_SESSION['user']      = $recovered['user'];
        $_SESSION['user_type'] = 'system';
        $_SESSION['persist']   = true;
        $_SESSION['nonce']     = $SecMan->create_nonce($recovered['user'], $client_ip, NONCE_SALT, true);
        if (empty($_SESSION['csrf_token'])) {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }
        if (!isset($_SESSION['theme_preference'])) {
            $_SESSION['theme_preference'] = 'auto';
        }

        // Rotate the persist token (consume already deleted the old row).
        $new_token = $SecMan->create_persist_token($recovered['user'], $client_ip);
        $persist_cookie_opts = [
            'expires'  => time() + WHP_PERSIST_SECONDS,
            'path'     => '/',
            'secure'   => $secureCookie,
            'httponly' => true,
            'samesite' => 'Lax',
        ];
        setcookie('whp_persist', $new_token, $persist_cookie_opts);
        // Re-pin the session cookie to the new (regen'd) SID with the long
        // lifetime so the user stops having to round-trip through recovery
        // on every browser restart.
        setcookie(session_name(), session_id(), $persist_cookie_opts);
        // Sync $_COOKIE so the elseif below sees the fresh token when it
        // computes $persist_active for the nonce max-age.
        $_COOKIE['whp_persist'] = $new_token;
        // Falls through to the elseif (isset($_SESSION['nonce'])) branch
        // which will validate the just-minted nonce and define AUTH_USER.
    } else {
        // Invalid / expired / IP-mismatched. consume_persist_token already
        // deleted the row defensively; clear the cookie so the rest of the
        // chain (and any future requests) doesn't re-attempt.
        setcookie('whp_persist', '', [
            'expires'  => time() - 3600,
            'path'     => '/',
            'secure'   => $secureCookie,
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
        unset($_COOKIE['whp_persist']);
    }
}

if ( isset($_POST["user"]) && isset($_POST["password"]) ) {
    $results = $SecMan->pam_login($_POST["user"], $_POST["password"]);
    if ( $results === true ) {
        // CSRF check on login — validate the per-render token before doing
        // anything privileged. Failures emit the same generic auth error so
        // we don't disclose CSRF status to attackers.
        $login_csrf_ok = hash_equals((string)($_SESSION['login_csrf'] ?? ''), (string)($_POST['login_csrf'] ?? ''));
        if (!$login_csrf_ok) {
            unset($_SESSION['login_csrf']);
            $_SESSION['login_error'] = 'Invalid username or password.';
            header('Location: /login.php');
            exit;
        }

        // Session-fixation defense: regenerate FIRST so any pre-existing SID
        // an attacker may have fixated is invalidated before we write the
        // privileged session payload below. Anything we set into $_SESSION
        // (or any setcookie that re-emits the session cookie) must run after
        // this point so it lands on the freshly-minted SID.
        session_regenerate_id(true);
        // The login CSRF token has fulfilled its purpose; drop it so the
        // regular per-session csrf_token (set below) takes over.
        unset($_SESSION['login_csrf']);

        // "Stay logged in" — apply persistence AFTER regen so the persist
        // cookies (whp_persist marker + re-emitted session cookie) carry the
        // NEW SID with the long lifetime.
        $stay_logged_in = !empty($_POST['stay_logged_in']);
        if ($stay_logged_in) {
            $_SESSION['persist'] = true;
            $cookie_opts = [
                'expires'  => time() + WHP_PERSIST_SECONDS,
                'path'     => '/',
                'secure'   => $secureCookie,
                'httponly' => true,
                'samesite' => 'Lax',
            ];
            // Mint a real persistent-auth token (selector:verifier). The cookie
            // is no longer just a "1" flag — it carries server-verifiable
            // credentials so a returning browser can recover its session even
            // after PHPSESSID is gone. Multi-device: this INSERTs a new row,
            // it does NOT touch any other device's token for the same user.
            $persist_token = $SecMan->create_persist_token($_POST['user'], $NetInfo->get_client_ip());
            setcookie('whp_persist', $persist_token, $cookie_opts);
            // session_id() now returns the post-regen SID, so this re-emit
            // pins the long lifetime to the new (privileged) session.
            setcookie(session_name(), session_id(), $cookie_opts);
        }

        define('AUTH_USER',$_POST["user"]);
        define('HOME_DIR', $SecMan->get_home_dir($_POST["user"]));
        $_SESSION['user'] = $_POST["user"];
        $_SESSION['user_type'] = 'system';

        // Load account type from client_allowances
        $_SESSION['account_type'] = 'full';
        if ($_POST["user"] !== 'root') {
            try {
                require_once('/docker/whp/web/libs/mysqlmgmt.php');
                $login_db = (new mysqlmgmt())->getMySQLConnection();
                if ($login_db) {
                    $at_stmt = $login_db->prepare("SELECT account_type FROM whp.client_allowances WHERE username = ?");
                    $at_stmt->execute([$_POST["user"]]);
                    $at_row = $at_stmt->fetch(PDO::FETCH_ASSOC);
                    if ($at_row && !empty($at_row['account_type'])) {
                        $_SESSION['account_type'] = $at_row['account_type'];
                    }
                }
            } catch (Exception $e) {
                error_log("Failed to load account_type for " . $_POST["user"] . ": " . $e->getMessage());
            }
        }
        
        $user_ip = $NetInfo->get_client_ip();
        $nonce = $SecMan->create_nonce($_POST['user'], $user_ip, NONCE_SALT, $stay_logged_in);
        $_SESSION['nonce'] = $nonce;

        // Generate CSRF token for this session
        if (empty($_SESSION['csrf_token'])) {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }

        // Initialize theme preference if not set (default to auto/system preference)
        if (!isset($_SESSION['theme_preference'])) {
            $_SESSION['theme_preference'] = 'auto';
        }

        // Record successful login and clear failed attempts
        $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
        $SecManager->record_successful_login($user_ip, $_POST["user"], $user_agent);

        // Honor deep-link return URL (set when an unauth request to a
        // protected page was redirected to /login.php). Must be a local
        // path; anything else falls back to /index.php.
        $redirect_to = '/index.php';
        if (!empty($_SESSION['login_return_to'])) {
            $candidate = $_SESSION['login_return_to'];
            unset($_SESSION['login_return_to']);
            if (is_string($candidate) && strlen($candidate) > 0 && $candidate[0] === '/' && strpos($candidate, '//') !== 0) {
                $redirect_to = $candidate;
            }
        }
        header('Location: ' . $redirect_to);
        exit();
    } else {
        // PAM login failed, try delegated user authentication
        $delegatedUserManager = new DelegatedUserManager();
        $user_ip = $NetInfo->get_client_ip();
        $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
        
        $delegated_result = $delegatedUserManager->authenticateDelegatedUser(
            $_POST["user"], 
            $_POST["password"], 
            $user_ip, 
            $user_agent
        );
        
        if ($delegated_result['success']) {
            // CSRF check on delegated login — same shared form, same token.
            $login_csrf_ok = hash_equals((string)($_SESSION['login_csrf'] ?? ''), (string)($_POST['login_csrf'] ?? ''));
            if (!$login_csrf_ok) {
                unset($_SESSION['login_csrf']);
                $_SESSION['login_error'] = 'Invalid username or password.';
                header('Location: /login.php');
                exit;
            }

            // Session-fixation defense: regenerate FIRST so any fixated SID is
            // discarded before we write the privileged session payload.
            session_regenerate_id(true);
            unset($_SESSION['login_csrf']);

            // "Stay logged in" — apply persistence AFTER regen so persist
            // cookies pin the long lifetime to the NEW (privileged) SID.
            $stay_logged_in = !empty($_POST['stay_logged_in']);
            if ($stay_logged_in) {
                $_SESSION['persist'] = true;
                $delegated_persist_opts = [
                    'expires'  => time() + WHP_PERSIST_SECONDS,
                    'path'     => '/',
                    'secure'   => $secureCookie,
                    'httponly' => true,
                    'samesite' => 'Lax',
                ];
                // Delegated logins intentionally keep the legacy "1" flag form
                // instead of a real persist token. A token stored under the
                // parent username and later recovered as a system user would
                // skip the delegated permission filters — a privilege
                // escalation. v1 delegated users get the session-cookie
                // lifetime fix (below) so single-device persistence still
                // works, but they don't benefit from token-based recovery.
                setcookie('whp_persist', '1', $delegated_persist_opts);
                // Mirror the PAM path: re-emit the PHP session cookie with
                // the long lifetime so the SID actually survives 7 days. This
                // was missing on the delegated path and was half of why
                // Stay-Logged-In didn't work for delegated accounts.
                setcookie(session_name(), session_id(), $delegated_persist_opts);
            }
            
            // For delegated users, AUTH_USER should be the parent user for resource access
            // but we track the actual delegated user separately
            define('AUTH_USER', $delegated_result['user']['parent_username']);
            $_SESSION['user'] = $delegated_result['user']['parent_username'];
            $_SESSION['user_type'] = 'delegated';
            $_SESSION['delegated_user'] = $delegated_result['user']['username'];
            $_SESSION['parent_user'] = $delegated_result['user']['parent_username'];
            $_SESSION['delegated_session_id'] = $delegated_result['session_id'];
            $_SESSION['delegated_user_id'] = $delegated_result['user']['id'];
            
            // For delegated users, set HOME_DIR to parent's home
            define('HOME_DIR', $SecMan->get_home_dir($delegated_result['user']['parent_username']));
            
            // Create a nonce for session management (using parent username for consistency)
            $nonce = $SecMan->create_nonce($delegated_result['user']['parent_username'], $user_ip, NONCE_SALT, $stay_logged_in);
            $_SESSION['nonce'] = $nonce;
            
            // Generate CSRF token for this session
            if (empty($_SESSION['csrf_token'])) {
                $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
            }

            // Initialize theme preference if not set (default to auto/system preference)
            if (!isset($_SESSION['theme_preference'])) {
                $_SESSION['theme_preference'] = 'auto';
            }

            // Honor deep-link return URL (set by the unauth redirect below).
            $redirect_to = '/index.php';
            if (!empty($_SESSION['login_return_to'])) {
                $candidate = $_SESSION['login_return_to'];
                unset($_SESSION['login_return_to']);
                if (is_string($candidate) && strlen($candidate) > 0 && $candidate[0] === '/' && strpos($candidate, '//') !== 0) {
                    $redirect_to = $candidate;
                }
            }
            header('Location: ' . $redirect_to);
            exit();
        } else {
            // Both PAM and delegated login failed
            $SecManager->record_failed_login($user_ip, $_POST["user"], $user_agent);
            
            // Set error message and redirect back to login
            $_SESSION['login_error'] = 'Invalid username or password.';
            header('Location: /login.php');
            exit();
        }
    }
}elseif ( isset($_SESSION['nonce'])) {
    // Test login details before getting Nonce configured.
    // The cookie is the source of truth for "is this a Stay-Logged-In session"
    // because it survives PHP session-file GC; the session flag is just a
    // request-scoped cache. Either being set means we apply the long maxAge.
    $persist_active = !empty($_SESSION['persist']) || !empty($_COOKIE['whp_persist']);
    $nonce_max_age = $persist_active ? WHP_PERSIST_SECONDS : 900;
    $nonce_check = $SecMan->check_nonce($_SESSION['nonce'], $NetInfo->get_client_ip(), $nonce_max_age);
    if ( $nonce_check === false ) {
        unset($_SESSION['nonce']);
        if ( $_SERVER['SCRIPT_FILENAME'] != '/docker/whp/web/login.php' && 
             $_SERVER['SCRIPT_FILENAME'] != '/docker/whp/web/blocked.php' && 
             !is_api_endpoint()) {
            if ( empty($_SERVER['HTTPS'] ) ) {
                $protocol = "http://";
                $port = "8080";
            } else {
                $protocol = "https://";
                $port = "8443";
            }
            $domain = $_SERVER['SERVER_NAME'];
            $redirect_address = $protocol . $domain . ":" . $port . "/login.php";
            header('Location: ' . $redirect_address );
            exit;
        }
    }elseif (is_array($nonce_check) ) {
        // Check if this is a delegated user session
        if (isset($_SESSION['user_type']) && $_SESSION['user_type'] === 'delegated') {
            // Validate delegated user session
            if (isset($_SESSION['delegated_session_id'])) {
                $delegatedUserManager = new DelegatedUserManager();
                $delegated_user = $delegatedUserManager->getUserBySession($_SESSION['delegated_session_id']);
                
                if ($delegated_user) {
                    // For delegated users, AUTH_USER should be the parent user for resource access
                    define('AUTH_USER', $delegated_user['parent_username']);
                    $_SESSION['user'] = $delegated_user['parent_username'];
                    $_SESSION['delegated_user'] = $delegated_user['username'];
                    $_SESSION['parent_user'] = $delegated_user['parent_username'];
                    $_SESSION['delegated_user_id'] = $delegated_user['id'];
                    $_SESSION['delegated_display_name'] = $delegated_user['display_name'] ?? '';
                    
                    // Store delegated user permissions in session for menu filtering
                    $_SESSION['delegated_permissions'] = [
                        'can_manage_domains' => $delegated_user['can_manage_domains'],
                        'can_manage_sites' => $delegated_user['can_manage_sites'],
                        'can_manage_databases' => $delegated_user['can_manage_databases'],
                        'can_manage_email' => $delegated_user['can_manage_email'],
                        'can_manage_files' => $delegated_user['can_manage_files'],
                        'can_manage_backups' => $delegated_user['can_manage_backups'],
                        'can_view_terminal' => $delegated_user['can_view_terminal'],
                        'can_manage_wordpress' => $delegated_user['can_manage_wordpress']
                    ];
                    
                    define('HOME_DIR', $SecMan->get_home_dir($delegated_user['parent_username']));
                } else {
                    // Delegated session expired or invalid, redirect to login
                    unset($_SESSION['nonce']);
                    unset($_SESSION['user_type']);
                    unset($_SESSION['delegated_session_id']);
                    header('Location: /login.php');
                    exit();
                }
            } else {
                // No delegated session ID, redirect to login
                unset($_SESSION['nonce']);
                unset($_SESSION['user_type']);
                header('Location: /login.php');
                exit();
            }
        } else {
            // Regular system user
            define('AUTH_USER',$nonce_check['user']);
            define('HOME_DIR',$SecMan->get_home_dir($nonce_check['user']));
            $_SESSION['user'] = $nonce_check['user'];
            $_SESSION['user_type'] = 'system';
        }
        
        $user_ip = $NetInfo->get_client_ip();
        // Carry forward the persist flag from the row we just consumed so the
        // refreshed nonce keeps Stay-Logged-In status across this request.
        $refresh_persist = !empty($nonce_check['persist']);
        $nonce = $SecMan->create_nonce($nonce_check['user'], $user_ip, NONCE_SALT, $refresh_persist);
        $_SESSION['nonce'] = $nonce;
        if ($refresh_persist) {
            $_SESSION['persist'] = true;
        }
        
        // Generate CSRF token for this session if not already set
        if (empty($_SESSION['csrf_token'])) {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }

        // Initialize theme preference if not set (default to auto/system preference)
        if (!isset($_SESSION['theme_preference'])) {
            $_SESSION['theme_preference'] = 'auto';
        }
    } else {
        error_log("Invalid nonce check for user " . ($_SESSION['user'] ?? '?'));
        // Redirect to login for security
        header('Location: /login.php');
        exit();
    }
}else {
    // Don't require authentication for login.php, blocked.php, or API endpoints
    if ( $_SERVER['SCRIPT_FILENAME'] != '/docker/whp/web/login.php' &&
         $_SERVER['SCRIPT_FILENAME'] != '/docker/whp/web/blocked.php' &&
         !is_api_endpoint()) {
        // Stash the requested URL so we can deep-link back after login.
        // GET-only (don't re-replay form POSTs). Must be local and not
        // already a login/blocked target.
        if ($_SERVER['REQUEST_METHOD'] === 'GET'
            && !empty($_SERVER['REQUEST_URI'])
            && strpos($_SERVER['REQUEST_URI'], '/login.php') !== 0
            && strpos($_SERVER['REQUEST_URI'], '/blocked.php') !== 0
            && $_SERVER['REQUEST_URI'][0] === '/'
            && strpos($_SERVER['REQUEST_URI'], '//') !== 0) {
            $_SESSION['login_return_to'] = $_SERVER['REQUEST_URI'];
        }
        if ( empty($_SERVER['HTTPS'] ) ) {
            $protocol = "http://";
            $port = "8080";
        } else {
            $protocol = "https://";
            $port = "8443";
        }
        $domain = $_SERVER['SERVER_NAME'];
        $redirect_address = $protocol . $domain . ":" . $port . "/login.php";
        header('Location: ' . $redirect_address );
        exit;
    }
}

if ( isset($_REQUEST['whp-action']) && $_REQUEST['whp-action'] == 'logout') {
    // Full teardown — previously this only unset $_SESSION['nonce'], leaving
    // the SQLite nonce row, the persist cookie, and the PHP session intact.
    // That meant Stay-Logged-In sessions weren't really logged out.
    if (!empty($_SESSION['nonce']) && isset($SecMan)) {
        $SecMan->delete_nonce($_SESSION['nonce']);
    }
    // Delete the current device's persist-token row before clearing the
    // cookie. Do NOT call delete_persist_tokens_by_user — other devices
    // that ticked Stay-Logged-In on this same account must stay logged in.
    if (!empty($_COOKIE['whp_persist'])
        && preg_match('/^([a-f0-9]{16}):/', $_COOKIE['whp_persist'], $persist_match)
        && isset($SecMan)) {
        $SecMan->delete_persist_token_by_selector($persist_match[1]);
    }
    setcookie('whp_persist', '', [
        'expires'  => time() - 3600,
        'path'     => '/',
        'secure'   => !empty($_SERVER['HTTPS']),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    $_SESSION = [];
    if (ini_get('session.use_cookies')) {
        $params = session_get_cookie_params();
        setcookie(session_name(), '', [
            'expires'  => time() - 42000,
            'path'     => $params['path'],
            'domain'   => $params['domain'],
            'secure'   => $params['secure'],
            'httponly' => $params['httponly'],
            'samesite' => $params['samesite'] ?? 'Lax',
        ]);
    }
    @session_destroy();

    if ( $_SERVER['SCRIPT_FILENAME'] != '/docker/whp/web/login.php' && !is_api_endpoint()) {
        if ( empty($_SERVER['HTTPS'] ) ) {
            $protocol = "http://";
            $port = "8080";
        } else {
            $protocol = "https://";
            $port = "8443";
        }
        $domain = $_SERVER['SERVER_NAME'];
        $redirect_address = $protocol . $domain . ":" . $port . "/login.php";
        header('Location: ' . $redirect_address );
        exit;
    }
}

// Check page permissions for authenticated users
if (defined('AUTH_USER') && $_SERVER['SCRIPT_FILENAME'] == '/docker/whp/web/index.php') {
    check_page_permission();
}

// CSRF Protection for all POST requests (except login, Adminer, and filemanager)
if (defined('AUTH_USER') && $_SERVER['REQUEST_METHOD'] === 'POST') {
    // Skip CSRF check for login form, Adminer, and filemanager
    if (!isset($_POST['user']) && !isset($_POST['password']) && !is_adminer_endpoint() && !is_filemanager_endpoint()) {
        if (!validate_csrf_token()) {
            if (is_api_endpoint()) {
                header('HTTP/1.1 403 Forbidden');
                header('Content-Type: application/json');
                // Emit both shapes — `error` matches the convention used by
                // wordpress-ajax/backups/etc. frontends, `message`/`code`/`status`
                // is kept for any older caller that expects them. Without
                // `error`, the wordpress.php import handler's
                // `if (data.error) { showAlert(...) }` silently treats a CSRF
                // rejection as success and reloads — that surfaced 2026-06-01
                // during the alsacorp/alsaeurope import session.
                //
                // Message wording targets the operator: "session expired"
                // points them at the actual fix (page refresh) rather than
                // "CSRF token" jargon.
                echo json_encode([
                    'error'   => 'Your session expired — please refresh the page and try again.',
                    'status'  => 'error',
                    'message' => 'CSRF token validation failed',
                    'code'    => 'csrf_session_expired',
                ]);
                exit;
            } else {
                header('HTTP/1.1 403 Forbidden');
                die('Your session expired. Please refresh the page and try again.');
            }
        }
    }
}

// Fix file ownership for filemanager operations.
// The file manager runs as root, so all created/modified files are owned by root.
// register_shutdown_function is required because index.php calls exit() via Json::jexit(),
// which prevents auto_append_file from executing.
if (is_filemanager_endpoint() && defined('AUTH_USER') && defined('HOME_DIR') && !empty(AUTH_USER) && !empty(HOME_DIR)) {
    register_shutdown_function(function() {
        $user = escapeshellarg(AUTH_USER . ':' . AUTH_USER);
        $home = escapeshellarg(HOME_DIR);
        // -mindepth 1 skips the home dir itself, which must stay root-owned for SFTP chroot.
        $cmd = "/usr/bin/find {$home} -mindepth 1 -user root -exec /bin/chown {$user} {} + 2>/dev/null";
        shell_exec($cmd);
    });
}