File size: 10,045 Bytes
ec8d46e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
<?php
/** FENNEC encrypted memory store. PHP 8.1+ with PDO_SQLite. No provider API calls. */
declare(strict_types=1);
ini_set('display_errors', '0');
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, private');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
function reply(int $status, array $body): void {
    http_response_code($status);
    echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
    exit;
}
function valid_hex($value): bool { return is_string($value) && preg_match('/\A[a-f0-9]{64}\z/D', $value) === 1; }
function is_within(string $path, string $root): bool { return $path === $root || strpos($path, $root . DIRECTORY_SEPARATOR) === 0; }
try {
    $configFile = __DIR__ . '/fennec-memory-config.php';
    if (!is_file($configFile)) reply(503, ['error' => 'Memory setup is incomplete: install fennec-memory-config.php.']);
    $config = require $configFile;
    if (!is_array($config)) reply(503, ['error' => 'Memory configuration is invalid.']);
    $origin = $config['allowed_origin'] ?? '';
    if (!is_string($origin) || !preg_match('~\Ahttps://[a-zA-Z0-9.-]+(?::[0-9]+)?\z~D', $origin)) reply(503, ['error' => 'Configure the HTTPS website origin for memory.']);
    if (empty($_SERVER['HTTPS']) || strtolower((string)$_SERVER['HTTPS']) === 'off') reply(400, ['error' => 'Memory requires HTTPS.']);
    if (isset($_SERVER['HTTP_ORIGIN']) && $_SERVER['HTTP_ORIGIN'] !== $origin) reply(403, ['error' => 'Origin not allowed.']);
    if (($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '') === 'cross-site') reply(403, ['error' => 'Cross-site memory requests are not allowed.']);
    $method = $_SERVER['REQUEST_METHOD'] ?? '';
    if (!in_array($method, ['GET', 'POST'], true)) { header('Allow: GET, POST'); reply(405, ['error' => 'Method not allowed.']); }
    $id = $_GET['id'] ?? null; $provider = $_GET['provider'] ?? null;
    if (!valid_hex($id) || !in_array($provider, ['openai', 'arc'], true)) reply(401, ['error' => 'Memory identifier or provider is invalid. Reload the updated FENNEC page.']);
    // CGI/FastCGI hosts may remove Authorization before PHP sees it. A dedicated
    // header carries the SAME derived storage secret, never the original API key.
    $customToken = $_SERVER['HTTP_X_FENNEC_MEMORY_TOKEN'] ?? null;
    $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
    if (function_exists('getallheaders')) {
        foreach (getallheaders() as $name => $value) {
            if ($customToken === null && strcasecmp($name, 'X-Fennec-Memory-Token') === 0) $customToken = $value;
            if ($auth === '' && strcasecmp($name, 'Authorization') === 0) $auth = $value;
        }
    }
    if ($customToken !== null) {
        if (!valid_hex($customToken)) reply(401, ['error' => 'Memory access token is invalid. Reload the updated FENNEC page.']);
        $token = $customToken;
    } elseif (is_string($auth) && preg_match('/\ABearer ([a-f0-9]{64})\z/D', $auth, $match)) {
        $token = $match[1]; // Compatibility with the original client.
    } else {
        reply(401, ['error' => 'Memory access token did not reach PHP. Upload both the updated HTML and PHP files, then reload.']);
    }
    // The identifier alone is insufficient: an independent derived bearer secret
    // is required to locate a vault. Neither secret is the encryption key.
    $vault = hash('sha256', "fennec-storage-v1|$provider|$id|" . $token);
    unset($auth, $match, $customToken, $token);
    $before = $_GET['before'] ?? null;
    if ($before !== null && (!is_string($before) || !preg_match('/\A[1-9][0-9]{0,14}\z/D', $before))) reply(400, ['error' => 'Invalid history cursor.']);
    $envelope = null;
    if ($method === 'POST') {
        if (strtolower(trim(explode(';', $_SERVER['CONTENT_TYPE'] ?? '')[0])) !== 'application/json') reply(415, ['error' => 'JSON is required.']);
        if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 400000) reply(413, ['error' => 'Encrypted conversation is too large.']);
        $raw = file_get_contents('php://input', false, null, 0, 400001);
        if ($raw === false || strlen($raw) > 400000) reply(413, ['error' => 'Encrypted conversation is too large.']);
        try { $body = json_decode($raw, true, 16, JSON_THROW_ON_ERROR); } catch (JsonException $e) { reply(400, ['error' => 'Invalid JSON.']); }
        $envelope = is_array($body) ? ($body['envelope'] ?? null) : null;
        if (!is_array($envelope) || count($envelope) !== 4 || ($envelope['v'] ?? null) !== 1 || !is_string($envelope['id'] ?? null) || !preg_match('/\A[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\z/D', $envelope['id'])) reply(400, ['error' => 'Invalid encrypted envelope.']);
        foreach (['iv', 'ciphertext'] as $field) if (!is_string($envelope[$field] ?? null)) reply(400, ['error' => 'Invalid encrypted envelope.']);
        $iv = base64_decode($envelope['iv'], true); $ciphertext = base64_decode($envelope['ciphertext'], true);
        if ($iv === false || strlen($iv) !== 12 || base64_encode($iv) !== $envelope['iv'] || $ciphertext === false || strlen($ciphertext) < 16 || strlen($ciphertext) > 240000 || base64_encode($ciphertext) !== $envelope['ciphertext']) reply(400, ['error' => 'Invalid encrypted data.']);
        $serialized = json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
        $bytes = strlen($serialized) + 1024;
    }
    // Refuse a storage directory within any web-accessible document root.
    // Configure all relevant roots if this cPanel account hosts multiple sites.
    $storage = realpath((string)($config['storage_dir'] ?? ''));
    $documentRoot = realpath($_SERVER['DOCUMENT_ROOT'] ?? '');
    if ($storage === false || !is_dir($storage) || !is_writable($storage) || $documentRoot === false) reply(503, ['error' => 'Create and configure a private writable memory folder outside your website folders.']);
    $roots = array_merge([$documentRoot, __DIR__], $config['public_roots'] ?? []);
    foreach ($roots as $root) { $realRoot = realpath((string)$root); if ($realRoot !== false && is_within($storage, $realRoot)) reply(503, ['error' => 'The memory folder must be outside all public website folders.']); }
    if (!extension_loaded('pdo_sqlite')) reply(503, ['error' => 'Enable the PDO SQLite PHP extension in cPanel.']);
    umask(0077);
    $db = new PDO('sqlite:' . $storage . '/fennec-memory.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
    $db->exec('PRAGMA busy_timeout=5000');
    $db->exec('CREATE TABLE IF NOT EXISTS entries (seq INTEGER PRIMARY KEY AUTOINCREMENT, vault TEXT NOT NULL, entry_id TEXT NOT NULL, envelope TEXT NOT NULL, bytes INTEGER NOT NULL, UNIQUE(vault, entry_id))');
    $db->exec('CREATE INDEX IF NOT EXISTS entries_vault_seq ON entries(vault, seq)');
    $db->exec('CREATE TABLE IF NOT EXISTS rates (bucket TEXT PRIMARY KEY, count INTEGER NOT NULL, expires INTEGER NOT NULL)');
    $db->exec('BEGIN IMMEDIATE');
    $now = time();
    $cleanup = $db->prepare('DELETE FROM rates WHERE expires < ?'); $cleanup->execute([$now]);
    $bucket = hash('sha256', (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown')) . ':' . intdiv($now, 60);
    $rate = $db->prepare('SELECT count FROM rates WHERE bucket = ?'); $rate->execute([$bucket]);
    $count = (int)$rate->fetchColumn();
    if ($count >= 120) { $db->exec('ROLLBACK'); header('Retry-After: 60'); reply(429, ['error' => 'Too many memory requests. Retry in a minute.']); }
    $bump = $db->prepare('INSERT OR REPLACE INTO rates(bucket, count, expires) VALUES (?, ?, ?)'); $bump->execute([$bucket, $count + 1, $now + 120]);
    if ($method === 'GET') {
        $query = $db->prepare('SELECT seq, envelope FROM entries WHERE vault = ? AND seq < ? ORDER BY seq DESC LIMIT 51');
        $query->execute([$vault, $before === null ? PHP_INT_MAX : (int)$before]);
        $rows = $query->fetchAll(PDO::FETCH_ASSOC); $more = count($rows) > 50; $rows = array_slice($rows, 0, 50);
        $entries = array_map(static function(array $row): array { return ['seq' => (int)$row['seq'], 'envelope' => json_decode($row['envelope'], true, 16, JSON_THROW_ON_ERROR)]; }, $rows);
        $db->exec('COMMIT');
        reply(200, ['entries' => $entries, 'before' => $more ? (int)$rows[count($rows) - 1]['seq'] : null]);
    }
    // Append-only, idempotent writes preserve concurrent conversations from tabs/devices.
    $existing = $db->prepare('SELECT seq FROM entries WHERE vault = ? AND entry_id = ?'); $existing->execute([$vault, $envelope['id']]);
    $seq = $existing->fetchColumn();
    if ($seq !== false) { $db->exec('COMMIT'); reply(200, ['seq' => (int)$seq]); }
    $usage = $db->prepare('SELECT COALESCE(SUM(bytes), 0) FROM entries WHERE vault = ?'); $usage->execute([$vault]);
    $vaultBytes = (int)$usage->fetchColumn();
    $totalBytes = (int)$db->query('SELECT COALESCE(SUM(bytes), 0) FROM entries')->fetchColumn();
    $vaultLimit = max(1, (int)($config['vault_limit_bytes'] ?? 16777216));
    $totalLimit = max(1, (int)($config['total_limit_bytes'] ?? 134217728));
    if ($vaultBytes + $bytes > $vaultLimit || $totalBytes + $bytes > $totalLimit) { $db->exec('COMMIT'); reply(507, ['error' => 'Memory storage is full. Contact the site administrator; previous conversations are preserved.']); }
    $insert = $db->prepare('INSERT INTO entries(vault, entry_id, envelope, bytes) VALUES (?, ?, ?, ?)');
    $insert->execute([$vault, $envelope['id'], $serialized, $bytes]); $seq = (int)$db->lastInsertId();
    $db->exec('COMMIT'); reply(201, ['seq' => $seq]);
} catch (Throwable $e) {
    if (isset($db)) { try { $db->exec('ROLLBACK'); } catch (Throwable $ignored) {} }
    // Never log request bodies, Authorization headers, or private conversation material.
    reply(503, ['error' => 'Memory service is unavailable. Check the private folder permissions, PHP version, and SQLite support.']);
}