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.']);
}
|