<?php
/*
 * PuqHosting custom auth adapter for FileGator.
 *
 * Two-leg auth:
 *   1. WHMCS server → POST /_fm/sso.php with {login, pass, domain, exp, nonce, sig}
 *      where sig = HMAC-SHA256 of "login|pass|domain|exp|nonce" with the
 *      per-server shared secret. We also IP-allow-list the WHMCS source.
 *      → response: {url: 'https://.../_fm/?_sso=<one-time-token>'}
 *
 *   2. Client browser opens that URL. PuqHostingAuth::user() sees
 *      ?_sso=<token>, looks it up in private/sso_sessions/<token>.json,
 *      establishes a session, and deletes the token file (single use).
 *
 * The FTP credentials (login + pass + domain) are kept in the user session
 * — the storage adapter pulls them out per-request to open an FTP socket
 * to localhost:21 (Hestia ships vsftpd by default and PAMs against the
 * unix account, so all file ops execute as the correct UID).
 *
 * Security notes:
 *   - IP allowlist is enforced on /sso.php only (so the WHMCS server is the
 *     ONLY caller). Browser hits on /_fm/?_sso=... don't go through
 *     allowlist — that's fine, they need a valid one-time token anyway.
 *   - SSO tokens live 60s, single-use, stored in a dir that's writable by
 *     PHP but outside docroot (`../private/sso_sessions/`).
 *   - The shared secret is generated once at install time (32 random bytes
 *     hex-encoded) and shared between WHMCS and filegator config.
 */

namespace Filegator\Services\Auth\Adapters;

use Filegator\Services\Auth\AuthInterface;
use Filegator\Services\Auth\User;
use Filegator\Services\Auth\UsersCollection;
use Filegator\Services\Service;
use Filegator\Services\Session\SessionStorageInterface as Session;

class PuqHostingAuth implements Service, AuthInterface
{
    const SESSION_KEY = 'puq_auth_user';
    const SESSION_CREDS_KEY = 'puq_ftp_creds';
    const GUEST_USERNAME = 'guest';

    /** @var Session */
    protected $session;
    /** @var string */
    protected $tokenDir;

    public function __construct(Session $session)
    {
        $this->session = $session;
    }

    public function init(array $config = [])
    {
        // hmac_secret + allowed_ips are read by the standalone sso.php script
        // — kept in config so a single source of truth lives in
        // configuration.php. Ignored here.
        $this->tokenDir = rtrim((string) ($config['token_dir'] ?? __DIR__ . '/../../../../private/sso_sessions'), '/');
        if (!is_dir($this->tokenDir)) {
            @mkdir($this->tokenDir, 0750, true);
        }
    }

    /**
     * Called on every request via Router::__construct. Two paths:
     *   1. Browser landed with ?_sso=<token> → consume the token (written
     *      to disk by the standalone sso.php handshake script), set
     *      session, redirect to clean URL.
     *   2. Otherwise — return current session user or null (guest fallback
     *      happens at the framework level via getGuest()).
     *
     * The server-to-server handshake (POST /_fm/sso.php) is handled by
     * the standalone sso.php script — does NOT route through this user()
     * method. That keeps the handshake response deterministically JSON
     * (filegator's bootstrap has several `die('Folder not writable...')`
     * checks that would otherwise leak as non-JSON HTTP 200 bodies).
     */
    public function user(): ?User
    {
        $sso = isset($_GET['_sso']) ? (string) $_GET['_sso'] : '';
        if ($sso !== '') {
            $payload = $this->consumeSsoToken($sso);
            if ($payload) {
                $creds = [
                    'user'    => $payload['login'],
                    'pass'    => $payload['pass'],
                    'domain'  => $payload['domain'],
                ];
                // Mirror creds into both Symfony's bag AND native $_SESSION.
                // The FTP-adapter closure in configuration.php runs in a
                // DI scope where the Session service isn't easily reachable,
                // so it reads $_SESSION directly. Symfony's NativeSessionStorage
                // bag values land at $_SESSION['_sf2_attributes'][KEY] — NOT
                // at $_SESSION[KEY] — so the closure was always seeing an
                // empty array and falling back to NullAdapter (no FTP attempt
                // at all). Writing to $_SESSION directly fixes that.
                $this->session->set(self::SESSION_CREDS_KEY, $creds);
                $this->session->set(self::SESSION_KEY, $this->buildUser($payload['login']));
                if (session_status() === PHP_SESSION_ACTIVE) {
                    $_SESSION[self::SESSION_CREDS_KEY] = $creds;
                }

                // Back-to-WHMCS cookie only. (NOT httpOnly — injected JS
                // needs to read it to wire the back link.) Path banner has
                // been removed; no display-context cookie needed.
                $cookieOpts = ['path' => '/', 'secure' => !empty($_SERVER['HTTPS']), 'httponly' => false, 'samesite' => 'Lax'];
                if (!empty($payload['back_url'])) {
                    setcookie('puq_back_url', (string) $payload['back_url'], $cookieOpts);
                }

                // Clean URL so the token doesn't sit in browser history /
                // Referer header.
                $clean = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');
                header('Location: ' . $clean);
                exit;
            }
            // Bad/expired token → fall through to guest.
        }

        $u = $this->session->get(self::SESSION_KEY, null);
        return $u instanceof User ? $u : null;
    }

    /** Look up + consume an SSO token. Returns payload or null. */
    protected function consumeSsoToken(string $token): ?array
    {
        if (!preg_match('/^[a-f0-9]{64}$/', $token)) return null;
        $file = $this->tokenDir . '/' . $token . '.json';
        if (!is_file($file)) return null;
        $raw = @file_get_contents($file);
        @unlink($file); // single-use — delete BEFORE validating
        if (!is_string($raw)) return null;
        $data = json_decode($raw, true);
        if (!is_array($data)) return null;
        if (($data['expires'] ?? 0) < time()) return null;
        return $data;
    }

    // ============================================================================
    // AuthInterface — HARDENED stubs.
    //
    // This adapter is the ONLY way to gain access to the file manager. Every
    // other authentication path (login form, users.json, change-password,
    // user CRUD) is explicitly disabled:
    //   - authenticate()  → always false → username+password POST to /login
    //                       can NEVER establish a session
    //   - find/add/update/delete → throw → admin/profile UI can't mutate users
    //   - getGuest()      → returns a permission-less guest (no read/write/zip),
    //                       so even read-only access is blocked without SSO
    //   - allUsers()      → empty collection → no user listing leak
    //
    // Combined with the storage adapter's hard FTP root chroot
    // (`/web/<domain>/public_html`) and vsftpd's own chroot to
    // `/home/<unix-user>/`, a successfully-SSO'd customer is double-locked
    // to their own document root — no path-traversal escape via filegator,
    // and no escape via the FTP layer beyond their unix home.
    // ============================================================================

    public function authenticate($username, $password): bool
    {
        // Hard-disabled. SSO is the only path. Log the attempt as a security
        // event so we can spot scanners / leaked-URL probes.
        if (function_exists('error_log')) {
            @error_log(sprintf(
                '[PuqHostingAuth] direct-login attempt rejected: user=%s ip=%s ua=%s',
                substr((string) $username, 0, 64),
                $_SERVER['REMOTE_ADDR'] ?? '?',
                substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 80)
            ));
        }
        return false;
    }

    public function forget() { $this->session->invalidate(); }
    public function store(User $user) { return $this->session->set(self::SESSION_KEY, $user); }
    public function find($username): ?User { return null; }
    public function update($username, User $user, $password = ''): User { throw new \RuntimeException('user CRUD disabled — auth is SSO-only'); }
    public function add(User $user, $password): User { throw new \RuntimeException('user CRUD disabled — auth is SSO-only'); }
    public function delete(User $user) { throw new \RuntimeException('user CRUD disabled — auth is SSO-only'); }

    public function getGuest(): User
    {
        // Permission-less guest. Even if filegator routes a guest somewhere,
        // they'll get 403 on any actual file operation.
        $g = new User();
        $g->setUsername(self::GUEST_USERNAME);
        $g->setName('Guest');
        $g->setRole('guest');
        $g->setHomedir('/');
        $g->setPermissions([]);
        return $g;
    }

    public function allUsers(): UsersCollection { return new UsersCollection(); }

    protected function buildUser(string $login): User
    {
        // Always-fixed homedir '/' — the storage adapter pins the FTP root
        // to /web/<domain>/public_html so '/' for filegator means that
        // directory and nothing higher. Hardcoded role 'user' (NOT 'admin')
        // strips access to user-management endpoints. Permissions are the
        // full file-ops set MINUS anything admin-side.
        $u = new User();
        $u->setUsername($login);
        $u->setName($login);
        $u->setRole('user');
        $u->setHomedir('/');
        $u->setPermissions(['read', 'write', 'upload', 'download', 'batchdownload', 'zip']);
        return $u;
    }
}
