diff --git a/.gitignore b/.gitignore index cb0f8ab..865e848 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ compiled/ /app/Config/database.php /vendors/* + +.rktsndbx-cache +data +pkg-cache diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..6cd1e2d --- /dev/null +++ b/.htaccess @@ -0,0 +1,17 @@ +FallbackResource /index.php + +DirectoryIndex index.php + +Options -MultiViews -Indexes +RewriteEngine On + +RewriteRule ^bootstrap-racket$ rkt.php [L,QSA] +RewriteRule ^bootstrap-racket-part$ rkt.php [L,QSA] +RewriteRule ^racket-pkg-index$ rktpkgs.php [L,QSA] +RewriteRule ^package$ package.php [L,QSA] +RewriteRule ^package-part$ package.php [L,QSA] +RewriteRule ^login$ login.php [L,QSA] +RewriteRule ^bootstrap-admin$ bootstrap-admin.php [L,QSA] +RewriteRule ^prompts$ prompts.php [L,QSA] +RewriteRule ^users$ users.php [L,QSA] +RewriteRule ^admin-config$ config.php [L,QSA] diff --git a/auth.php b/auth.php new file mode 100644 index 0000000..17ce216 --- /dev/null +++ b/auth.php @@ -0,0 +1,722 @@ +row = is_array($row) ? $row : array(); + } + + public function id() + { + return (int)($this->row['id'] ?? 0); + } + + public function email() + { + return (string)($this->row['email'] ?? ''); + } + + public function fullName() + { + return (string)($this->row['full_name'] ?? ''); + } + + public function displayName() + { + $name = $this->fullName(); + $email = $this->email(); + + if ($name !== '' && $email !== '') { + return $name . ' <' . $email . '>'; + } + + if ($name !== '') { + return $name; + } + + return $email; + } + + public function isAdmin() + { + return (int)($this->row['is_admin'] ?? 0) === 1; + } + + public function isEnabled() + { + return (int)($this->row['is_enabled'] ?? 0) === 1; + } + + public function createdAt() + { + return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null; + } + + public function updatedAt() + { + return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null; + } + + public function lastLoginAt() + { + return isset($this->row['last_login_at']) && $this->row['last_login_at'] !== null + ? (int)$this->row['last_login_at'] + : null; + } + + public function sessionExpiresAt() + { + return isset($this->row['session_expires_at']) && $this->row['session_expires_at'] !== null + ? (int)$this->row['session_expires_at'] + : null; + } + + public function raw() + { + return $this->row; + } +} + +class RacketSandboxAuth +{ + private $db; + private $sessionCookieName; + private $sessionTtlSeconds; + + public function __construct($dbFile, $options = array()) + { + $dir = dirname($dbFile); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0700, true)) { + throw new RacketSandboxAuthException('Could not create database directory: ' . $dir); + } + } + + $this->sessionCookieName = isset($options['cookie_name']) + ? (string)$options['cookie_name'] + : 'rkt_sandbox_session'; + + $this->sessionTtlSeconds = isset($options['session_ttl']) + ? (int)$options['session_ttl'] + : 12 * 3600; + + $this->db = new PDO('sqlite:' . $dbFile); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->db->exec('PRAGMA journal_mode = WAL'); + $this->db->exec('PRAGMA busy_timeout = 5000'); + + $this->init(); + } + + private function init() + { + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_login_at INTEGER NULL + )' + ); + + $this->migrateUsersTable(); + + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS auth_sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + user_agent TEXT NULL, + remote_addr TEXT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + )' + ); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_auth_sessions_user + ON auth_sessions(user_id)' + ); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires + ON auth_sessions(expires_at)' + ); + } + + private function migrateUsersTable() + { + $cols = $this->tableColumns('users'); + + if (isset($cols['username']) && !isset($cols['email'])) { + $this->db->exec('ALTER TABLE users RENAME COLUMN username TO email'); + $cols = $this->tableColumns('users'); + } + + if (!isset($cols['full_name'])) { + $this->db->exec("ALTER TABLE users ADD COLUMN full_name TEXT NOT NULL DEFAULT ''"); + $this->db->exec("UPDATE users SET full_name = email WHERE full_name = ''"); + } + } + + private function tableColumns($table) + { + $stmt = $this->db->query('PRAGMA table_info(' . $table . ')'); + $cols = array(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $cols[$row['name']] = true; + } + + return $cols; + } + + public function createUser($email, $fullName, $password, $isAdmin = false, $enabled = true) + { + $email = $this->safeEmail($email); + $fullName = $this->safeFullName($fullName); + $this->assertPasswordUsable($password); + + $now = time(); + $hash = password_hash($password, PASSWORD_DEFAULT); + + if ($hash === false) { + throw new RacketSandboxAuthException('Could not hash password.'); + } + + $stmt = $this->db->prepare( + 'INSERT INTO users + (email, full_name, password_hash, is_admin, is_enabled, created_at, updated_at) + VALUES + (:email, :full_name, :password_hash, :is_admin, :is_enabled, :created_at, :updated_at)' + ); + + $stmt->execute(array( + ':email' => $email, + ':full_name' => $fullName, + ':password_hash' => $hash, + ':is_admin' => $isAdmin ? 1 : 0, + ':is_enabled' => $enabled ? 1 : 0, + ':created_at' => $now, + ':updated_at' => $now, + )); + + return $this->getUserById((int)$this->db->lastInsertId()); + } + + public function login($email, $password) + { + $email = $this->safeEmail($email); + + $stmt = $this->db->prepare( + 'SELECT * + FROM users + WHERE email = :email' + ); + + $stmt->execute(array(':email' => $email)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new RacketSandboxAuthException('Invalid email address or password.'); + } + + if ((int)$row['is_enabled'] !== 1) { + throw new RacketSandboxAuthException('This account is disabled.'); + } + + if (!password_verify($password, $row['password_hash'])) { + throw new RacketSandboxAuthException('Invalid email address or password.'); + } + + if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) { + $this->setPasswordById((int)$row['id'], $password, false); + } + + $token = $this->createSession((int)$row['id']); + $this->setSessionCookie($token); + + $stmt = $this->db->prepare( + 'UPDATE users + SET last_login_at = :last_login_at + WHERE id = :id' + ); + + $stmt->execute(array( + ':last_login_at' => time(), + ':id' => (int)$row['id'], + )); + + return $this->getUserById((int)$row['id']); + } + + public function logout() + { + $token = $this->cookieToken(); + + if ($token !== null) { + $this->deleteSession($token); + } + + $this->clearSessionCookie(); + } + + public function currentUser() + { + $token = $this->cookieToken(); + + if ($token === null || !$this->validTokenSyntax($token)) { + return null; + } + + $stmt = $this->db->prepare( + 'SELECT + u.id, + u.email, + u.full_name, + u.is_admin, + u.is_enabled, + u.created_at, + u.updated_at, + u.last_login_at, + s.expires_at AS session_expires_at + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token = :token' + ); + + $stmt->execute(array(':token' => $token)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + if ((int)$row['is_enabled'] !== 1) { + $this->deleteSession($token); + return null; + } + + if (time() > (int)$row['session_expires_at']) { + $this->deleteSession($token); + return null; + } + + $this->touchSession($token); + + return new RacketSandboxUser($row); + } + + public function requireLoginHtml() + { + $user = $this->currentUser(); + + if ($user !== null) { + return $user; + } + + $this->loginRequiredHtml(); + } + + public function requireAdminHtml() + { + $user = $this->requireLoginHtml(); + + if ($user->isAdmin()) { + return $user; + } + + $this->messageHtml( + 'Not available', + 'Admin rights are required for this page.' + ); + } + + public function listUsers() + { + $stmt = $this->db->query( + 'SELECT + id, email, full_name, is_admin, is_enabled, + created_at, updated_at, last_login_at + FROM users + ORDER BY email COLLATE NOCASE' + ); + + $out = array(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = new RacketSandboxUser($row); + } + + return $out; + } + + public function getUser($id) + { + return $this->getUserById($id); + } + + public function setAdmin($userId, $isAdmin) + { + $this->updateUserFlag($userId, 'is_admin', $isAdmin); + } + + public function setEnabled($userId, $isEnabled) + { + $this->updateUserFlag($userId, 'is_enabled', $isEnabled); + + if (!$isEnabled) { + $stmt = $this->db->prepare( + 'DELETE FROM auth_sessions + WHERE user_id = :user_id' + ); + $stmt->execute(array(':user_id' => (int)$userId)); + } + } + + public function updateUser($userId, $email, $fullName) + { + $userId = (int)$userId; + $email = $this->safeEmail($email); + $fullName = $this->safeFullName($fullName); + + $stmt = $this->db->prepare( + 'UPDATE users + SET email = :email, + full_name = :full_name, + updated_at = :updated_at + WHERE id = :id' + ); + + $stmt->execute(array( + ':email' => $email, + ':full_name' => $fullName, + ':updated_at' => time(), + ':id' => $userId, + )); + + return $this->getUserById($userId); + } + + public function setPassword($email, $newPassword) + { + $email = $this->safeEmail($email); + + $stmt = $this->db->prepare( + 'SELECT id + FROM users + WHERE email = :email' + ); + + $stmt->execute(array(':email' => $email)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new RacketSandboxAuthException('Unknown user: ' . $email); + } + + $this->setPasswordById((int)$row['id'], $newPassword, true); + } + + public function deleteUser($userId) + { + $userId = (int)$userId; + + $this->db->beginTransaction(); + + try { + $stmt = $this->db->prepare( + 'DELETE FROM auth_sessions + WHERE user_id = :user_id' + ); + $stmt->execute(array(':user_id' => $userId)); + + $stmt = $this->db->prepare( + 'DELETE FROM users + WHERE id = :id' + ); + $stmt->execute(array(':id' => $userId)); + + $this->db->commit(); + } catch (Throwable $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + + throw $e; + } + } + + public function cleanupSessions() + { + $stmt = $this->db->prepare( + 'DELETE FROM auth_sessions + WHERE expires_at < :now' + ); + + $stmt->execute(array(':now' => time())); + return $stmt->rowCount(); + } + + private function getUserById($id) + { + $stmt = $this->db->prepare( + 'SELECT + id, email, full_name, is_admin, is_enabled, + created_at, updated_at, last_login_at + FROM users + WHERE id = :id' + ); + + $stmt->execute(array(':id' => (int)$id)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + return new RacketSandboxUser($row); + } + + private function createSession($userId) + { + $token = bin2hex(random_bytes(32)); + $now = time(); + + $stmt = $this->db->prepare( + 'INSERT INTO auth_sessions + (token, user_id, created_at, expires_at, last_seen_at, user_agent, remote_addr) + VALUES + (:token, :user_id, :created_at, :expires_at, :last_seen_at, :user_agent, :remote_addr)' + ); + + $stmt->execute(array( + ':token' => $token, + ':user_id' => (int)$userId, + ':created_at' => $now, + ':expires_at' => $now + $this->sessionTtlSeconds, + ':last_seen_at' => $now, + ':user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, + ':remote_addr' => $_SERVER['REMOTE_ADDR'] ?? null, + )); + + return $token; + } + + private function touchSession($token) + { + $now = time(); + + $stmt = $this->db->prepare( + 'UPDATE auth_sessions + SET last_seen_at = :last_seen_at, + expires_at = :expires_at + WHERE token = :token' + ); + + $stmt->execute(array( + ':last_seen_at' => $now, + ':expires_at' => $now + $this->sessionTtlSeconds, + ':token' => $token, + )); + } + + private function deleteSession($token) + { + $stmt = $this->db->prepare( + 'DELETE FROM auth_sessions + WHERE token = :token' + ); + + $stmt->execute(array(':token' => $token)); + } + + private function setPasswordById($userId, $newPassword, $deleteSessions) + { + $this->assertPasswordUsable($newPassword); + + $hash = password_hash($newPassword, PASSWORD_DEFAULT); + + if ($hash === false) { + throw new RacketSandboxAuthException('Could not hash password.'); + } + + $stmt = $this->db->prepare( + 'UPDATE users + SET password_hash = :password_hash, + updated_at = :updated_at + WHERE id = :id' + ); + + $stmt->execute(array( + ':password_hash' => $hash, + ':updated_at' => time(), + ':id' => (int)$userId, + )); + + if ($deleteSessions) { + $stmt = $this->db->prepare( + 'DELETE FROM auth_sessions + WHERE user_id = :user_id' + ); + + $stmt->execute(array(':user_id' => (int)$userId)); + } + } + + private function updateUserFlag($userId, $column, $value) + { + if ($column !== 'is_admin' && $column !== 'is_enabled') { + throw new RacketSandboxAuthException('Invalid flag column.'); + } + + $stmt = $this->db->prepare( + 'UPDATE users + SET ' . $column . ' = :value, + updated_at = :updated_at + WHERE id = :id' + ); + + $stmt->execute(array( + ':value' => $value ? 1 : 0, + ':updated_at' => time(), + ':id' => (int)$userId, + )); + } + + private function cookieToken() + { + $token = $_COOKIE[$this->sessionCookieName] ?? null; + + if (!is_string($token) || !$this->validTokenSyntax($token)) { + return null; + } + + return $token; + } + + private function setSessionCookie($token) + { + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + + setcookie($this->sessionCookieName, $token, array( + 'expires' => time() + $this->sessionTtlSeconds, + 'path' => '/', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + )); + } + + private function clearSessionCookie() + { + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + + setcookie($this->sessionCookieName, '', array( + 'expires' => time() - 3600, + 'path' => '/', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + )); + } + + private function validTokenSyntax($token) + { + return is_string($token) && preg_match('/^[a-f0-9]{64}$/', $token); + } + + private function safeEmail($email) + { + $email = strtolower(trim((string)$email)); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new RacketSandboxAuthException('Invalid email address.'); + } + + if (strlen($email) > 160) { + throw new RacketSandboxAuthException('Email address is too long.'); + } + + return $email; + } + + private function safeFullName($fullName) + { + $fullName = trim((string)$fullName); + + if ($fullName === '') { + throw new RacketSandboxAuthException('Full name is required.'); + } + + if (strlen($fullName) > 160) { + throw new RacketSandboxAuthException('Full name is too long.'); + } + + return $fullName; + } + + private function assertPasswordUsable($password) + { + if (!is_string($password) || strlen($password) < 10) { + throw new RacketSandboxAuthException('Password must be at least 10 characters.'); + } + } + + private function loginRequiredHtml() + { + $this->messageHtml( + 'Login required', + 'Please log in to continue.', + '

Login

' + ); + } + + private function messageHtml($title, $message, $extra = '') + { + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + + echo ' + + + +' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ' + + +

' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

+

' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

+' . $extra . ' + +'; + exit; + } +} diff --git a/b64parts.php b/b64parts.php new file mode 100644 index 0000000..192d39f --- /dev/null +++ b/b64parts.php @@ -0,0 +1,264 @@ +splitFile( + * __DIR__ . '/data/html-parsing.zip', + * 'html-parsing' + * ); + * + * print_r($manifest); + */ + +class Base64PartsException extends Exception +{ +} + +class Base64Parts +{ + private $dataDir; + private $maxBase64PartBytes; + + public function __construct($dataDir, $maxBase64PartBytes = null) + { + $this->dataDir = rtrim((string)$dataDir, '/'); + + /* + * Max grootte van de base64-text per part. + * Houd dit op 2 MiB tenzij je fetcher kleiner nodig heeft. + */ + $this->maxBase64PartBytes = $maxBase64PartBytes === null + ? 2 * 1024 * 1024 + : (int)$maxBase64PartBytes; + + if ($this->maxBase64PartBytes < 1024) { + throw new Base64PartsException('maxBase64PartBytes is te klein.'); + } + } + + public function splitFile($sourceFile, $name, $clearOldParts = true) + { + $sourceFile = (string)$sourceFile; + $name = $this->safeName($name); + + if (!is_file($sourceFile) || !is_readable($sourceFile)) { + throw new Base64PartsException('Bronbestand ontbreekt of is niet leesbaar: ' . $sourceFile); + } + + $this->ensureDataDir(); + + if ($clearOldParts) { + $this->removeParts($name); + } + + /* + * Base64 maakt van 3 binary bytes precies 4 tekstbytes. + * Door de binary chunk op een veelvoud van 3 te houden, krijgen + * alle niet-laatste parts een nette base64-lengte zonder padding. + */ + $maxBinaryChunkBytes = intdiv($this->maxBase64PartBytes, 4) * 3; + + if ($maxBinaryChunkBytes < 3) { + throw new Base64PartsException('Berekende binary chunk size is ongeldig.'); + } + + $in = fopen($sourceFile, 'rb'); + + if ($in === false) { + throw new Base64PartsException('Kan bronbestand niet openen: ' . $sourceFile); + } + + $parts = array(); + $nr = 1; + $totalBinaryBytes = 0; + $totalBase64Bytes = 0; + + while (!feof($in)) { + $bin = fread($in, $maxBinaryChunkBytes); + + if ($bin === false) { + fclose($in); + throw new Base64PartsException('Fout bij lezen van: ' . $sourceFile); + } + + if ($bin === '') { + break; + } + + $b64 = base64_encode($bin); + + if (strlen($b64) > $this->maxBase64PartBytes) { + fclose($in); + throw new Base64PartsException('Interne fout: base64 part is groter dan maximum.'); + } + + $partNumber = sprintf('%06d', $nr); + $partFile = $this->partFile($name, $partNumber); + + if (file_put_contents($partFile, $b64, LOCK_EX) === false) { + fclose($in); + throw new Base64PartsException('Kan part niet schrijven: ' . $partFile); + } + + $binaryBytes = strlen($bin); + $base64Bytes = strlen($b64); + + $parts[] = array( + 'number' => $partNumber, + 'file' => $partFile, + 'basename' => basename($partFile), + 'binary_bytes' => $binaryBytes, + 'base64_bytes' => $base64Bytes, + ); + + $totalBinaryBytes += $binaryBytes; + $totalBase64Bytes += $base64Bytes; + $nr++; + } + + fclose($in); + + if (count($parts) === 0) { + throw new Base64PartsException('Bronbestand is leeg: ' . $sourceFile); + } + + $manifest = array( + 'name' => $name, + 'source_file' => $sourceFile, + 'source_bytes' => filesize($sourceFile), + 'source_sha256' => hash_file('sha256', $sourceFile), + 'max_base64_bytes' => $this->maxBase64PartBytes, + 'binary_chunk_bytes' => $maxBinaryChunkBytes, + 'part_count' => count($parts), + 'total_binary_bytes' => $totalBinaryBytes, + 'total_base64_bytes' => $totalBase64Bytes, + 'parts' => $parts, + 'created_at' => gmdate('c'), + ); + + $manifestFile = $this->manifestFile($name); + + if (file_put_contents( + $manifestFile, + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", + LOCK_EX + ) === false) { + throw new Base64PartsException('Kan manifest niet schrijven: ' . $manifestFile); + } + + $manifest['manifest_file'] = $manifestFile; + + return $manifest; + } + + public function readPart($name, $partNumber) + { + $name = $this->safeName($name); + + if (!preg_match('/^[0-9]{6}$/', (string)$partNumber)) { + throw new Base64PartsException('Ongeldig partnummer: ' . $partNumber); + } + + $file = $this->partFile($name, $partNumber); + + if (!is_file($file) || !is_readable($file)) { + throw new Base64PartsException('Part ontbreekt of is niet leesbaar: ' . $file); + } + + $txt = file_get_contents($file); + + if ($txt === false) { + throw new Base64PartsException('Kan part niet lezen: ' . $file); + } + + return $txt; + } + + public function loadManifest($name) + { + $name = $this->safeName($name); + $file = $this->manifestFile($name); + + if (!is_file($file) || !is_readable($file)) { + throw new Base64PartsException('Manifest ontbreekt: ' . $file); + } + + $raw = file_get_contents($file); + + if ($raw === false) { + throw new Base64PartsException('Kan manifest niet lezen: ' . $file); + } + + $json = json_decode($raw, true); + + if (!is_array($json)) { + throw new Base64PartsException('Manifest is geen geldige JSON: ' . $file); + } + + return $json; + } + + public function removeParts($name) + { + $name = $this->safeName($name); + + foreach (glob($this->dataDir . '/' . $name . '.part.*.b64') ?: array() as $file) { + if (is_file($file)) { + @unlink($file); + } + } + + $manifest = $this->manifestFile($name); + + if (is_file($manifest)) { + @unlink($manifest); + } + } + + private function ensureDataDir() + { + if (!is_dir($this->dataDir)) { + if (!mkdir($this->dataDir, 0755, true)) { + throw new Base64PartsException('Kan data directory niet maken: ' . $this->dataDir); + } + } + + if (!is_writable($this->dataDir)) { + throw new Base64PartsException('Data directory is niet schrijfbaar: ' . $this->dataDir); + } + } + + private function safeName($name) + { + $name = (string)$name; + + if (!preg_match('/^[A-Za-z0-9_.+-]+$/', $name)) { + throw new Base64PartsException('Ongeldige naam: ' . $name); + } + + return $name; + } + + private function partFile($name, $partNumber) + { + return $this->dataDir . '/' . $name . '.part.' . $partNumber . '.b64'; + } + + private function manifestFile($name) + { + return $this->dataDir . '/' . $name . '.parts.json'; + } +} \ No newline at end of file diff --git a/base64config.php b/base64config.php new file mode 100644 index 0000000..3324be5 --- /dev/null +++ b/base64config.php @@ -0,0 +1,69 @@ + 8192, + 'package_zip_max_base64_kb' => 2048, + ); +} + +function load_base64_chunk_config() +{ + $defaults = base64_chunk_defaults(); + $config = is_file(BASE64_CHUNK_CONFIG_FILE) + ? require BASE64_CHUNK_CONFIG_FILE + : array(); + + if (!is_array($config)) { + $config = array(); + } + + foreach ($defaults as $key => $default) { + $value = (int)($config[$key] ?? $default); + $config[$key] = $value > 0 ? $value : $default; + } + + return array_intersect_key($config, $defaults); +} + +function save_base64_chunk_config($config) +{ + $defaults = base64_chunk_defaults(); + $clean = array(); + + foreach ($defaults as $key => $default) { + $value = (int)($config[$key] ?? $default); + + if ($value < 1) { + throw new InvalidArgumentException('Base64 chunk size must be at least 1 KiB.'); + } + + $clean[$key] = $value; + } + + $php = "requireAdminHtml(); + +$message = ''; +$error = ''; +$base64ChunkConfig = load_base64_chunk_config(); +$localTimezone = new DateTimeZone('Europe/Amsterdam'); + +function h($s) +{ + return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function t($key, $fallback = null) +{ + global $languageStore, $language; + + return $languageStore->translate($key, $language, $fallback); +} + +function post_value($name, $default = '') +{ + return $_POST[$name] ?? $default; +} + +function resolve_user_language($userSettings, $userId, $allowedLanguages) +{ + $language = isset($_GET['lang']) + ? (string)$_GET['lang'] + : (string)$userSettings->get($userId, 'language', 'en'); + + if (!in_array($language, $allowedLanguages, true)) { + $language = 'en'; + } + + $userSettings->set($userId, 'language', $language); + + return $language; +} + +function base64_binary_chunk_bytes_for_kib($kib) +{ + return racket_zip_binary_chunk_bytes_for_base64_kib($kib); +} + +function format_token_time($timestamp) +{ + global $localTimezone; + + $time = (new DateTimeImmutable('@' . (int)$timestamp))->setTimezone($localTimezone); + + return $time->format('Y-m-d H:i:s T'); +} + +function token_status_class($token) +{ + return time() <= (int)($token['expires_at'] ?? 0) + ? 'token-valid' + : 'token-expired'; +} + +$language = resolve_user_language( + $userSettings, + $currentUser->id(), + $languageStore->supportedLanguages() +); + +$languageStore->seedDefaults(array( + 'app.title' => array('en' => 'Racket sandbox', 'nl' => 'Racket sandbox'), + 'app.manage_prompts' => array('en' => 'Manage prompts', 'nl' => 'Prompts beheren'), + 'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'), + 'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'), + 'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'), + 'app.language' => array('en' => 'Language', 'nl' => 'Taal'), + 'app.logged_in_as' => array('en' => 'Logged in as:', 'nl' => 'Ingelogd als:'), + 'app.admin' => array('en' => 'Admin', 'nl' => 'Admin'), + 'app.back_to_sandbox' => array('en' => 'Back to Racket sandbox', 'nl' => 'Terug naar Racket sandbox'), + 'app.download_settings' => array('en' => 'Download settings', 'nl' => 'Downloadinstellingen'), + 'app.maintenance' => array('en' => 'Maintenance', 'nl' => 'Onderhoud'), + 'app.next_tokens' => array('en' => 'Next tokens', 'nl' => 'Next-tokens'), + 'app.current_next_tokens' => array('en' => 'Next tokens', 'nl' => 'Next-tokens'), + 'app.token' => array('en' => 'Token', 'nl' => 'Token'), + 'app.created_at' => array('en' => 'Created at', 'nl' => 'Aangemaakt op'), + 'app.expires_at' => array('en' => 'Expires at', 'nl' => 'Verloopt op'), + 'app.no_current_next_tokens' => array('en' => 'No next tokens.', 'nl' => 'Geen next-tokens.'), + 'app.remove_expired_tokens' => array('en' => 'Remove expired next tokens', 'nl' => 'Verlopen next-tokens verwijderen'), + 'app.racket_zip_chunk_kb' => array('en' => 'Racket installation max base64 chunk size (KiB)', 'nl' => 'Maximale base64-chunkgrootte Racket-installatie (KiB)'), + 'app.package_zip_chunk_kb' => array('en' => 'Package/module max base64 chunk size (KiB)', 'nl' => 'Maximale base64-chunkgrootte packages/modules (KiB)'), + 'app.save_configuration' => array('en' => 'Save configuration', 'nl' => 'Configuratie opslaan'), + 'app.configuration_saved' => array('en' => 'Configuration saved.', 'nl' => 'Configuratie opgeslagen.'), + 'app.racket_parts_regenerated' => array('en' => 'Racket installation parts regenerated:', 'nl' => 'Racket-installatie parts opnieuw gemaakt:'), + 'app.chunk_size_hint_v2' => array( + 'en' => 'Values are maximum base64 payload sizes in KiB. A 6144 KiB binary chunk becomes 8192 KiB base64. Racket installation parts are regenerated when this configuration is saved.', + 'nl' => 'Waarden zijn maximale base64-payloadgroottes in KiB. Een binaire chunk van 6144 KiB wordt 8192 KiB base64. Racket-installatie parts worden opnieuw gemaakt wanneer deze configuratie wordt opgeslagen.', + ), + 'app.effective_binary_chunk' => array('en' => 'Effective binary chunk', 'nl' => 'Effectieve binaire chunk'), + 'app.racket_zip_source' => array('en' => 'Racket installation source', 'nl' => 'Bronbestand Racket-installatie'), + 'app.racket_parts' => array('en' => 'Racket installation parts', 'nl' => 'Racket-installatie parts'), + 'app.racket_parts_current' => array('en' => 'current', 'nl' => 'actueel'), + 'app.racket_parts_missing' => array('en' => 'missing or outdated; save configuration to regenerate', 'nl' => 'ontbreken of verouderd; sla configuratie op om opnieuw te maken'), + 'app.expired_tokens_removed' => array('en' => 'Expired next tokens removed:', 'nl' => 'Verlopen next-tokens verwijderd:'), + 'app.cleanup_help' => array( + 'en' => 'Expired links should return an outdated information message to the AI agent. Cleanup only removes old token rows from SQLite.', + 'nl' => 'Verlopen links moeten een melding over verouderde informatie aan de AI-agent teruggeven. Opruimen verwijdert alleen oude tokenrijen uit SQLite.' + ), +)); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = post_value('action'); + + try { + if ($action === 'logout') { + $auth->logout(); + header('Location: /login.php'); + exit; + } elseif ($action === 'update_config') { + save_base64_chunk_config(array( + 'racket_zip_max_base64_kb' => post_value('racket_zip_max_base64_kb'), + 'package_zip_max_base64_kb' => post_value('package_zip_max_base64_kb'), + )); + $base64ChunkConfig = load_base64_chunk_config(); + $manifest = split_racket_zip_parts($base64ChunkConfig['racket_zip_max_base64_kb']); + $message = t('app.configuration_saved', 'Configuration saved.') . ' ' . + t('app.racket_parts_regenerated', 'Racket installation parts regenerated:') . ' ' . + (int)$manifest['part_count']; + } elseif ($action === 'cleanup_tokens') { + $deleted = $tokens->cleanup(); + $message = t('app.expired_tokens_removed', 'Expired next tokens removed:') . ' ' . $deleted; + } + } catch (Throwable $e) { + $error = $e->getMessage(); + } +} + +$racketPartsManifest = null; +$racketPartsCurrent = false; + +try { + $racketPartsManifest = load_racket_zip_parts_manifest(); + $racketPartsCurrent = racket_zip_parts_current( + $racketPartsManifest, + $base64ChunkConfig['racket_zip_max_base64_kb'] + ); +} catch (Throwable $e) { + $racketPartsManifest = null; + $racketPartsCurrent = false; +} + +if (!$racketPartsCurrent && is_file(RACKET_ZIP_FILE) && is_readable(RACKET_ZIP_FILE)) { + try { + $racketPartsManifest = split_racket_zip_parts($base64ChunkConfig['racket_zip_max_base64_kb']); + $racketPartsCurrent = true; + + if ($message === '') { + $message = t('app.racket_parts_regenerated', 'Racket installation parts regenerated:') . ' ' . + (int)$racketPartsManifest['part_count']; + } + } catch (Throwable $e) { + if ($error === '') { + $error = $e->getMessage(); + } + } +} + +$headerLanguages = array(); + +$currentNextTokens = array(); + +try { + $currentNextTokens = $tokens->tokens(); +} catch (Throwable $e) { + if ($error === '') { + $error = $e->getMessage(); + } +} + +foreach ($languageStore->supportedLanguages() as $lang) { + $headerLanguages[$lang] = $languageStore->languageLabel($lang); +} + +$styleVersion = @filemtime(__DIR__ . '/styles.css') ?: time(); + +header('Content-Type: text/html; charset=utf-8'); +?> + + + + +<?= h(t('app.configuration', 'Configuration')) ?> + + + + +
+ + t('app.configuration', 'Configuration'), + 'nav_items' => array( + array('label' => t('app.back_to_sandbox', 'Back to Racket sandbox'), 'url' => '/?lang=' . rawurlencode($language)), + array( + 'label' => t('app.manage_prompts', 'Manage prompts'), + 'url' => '/prompts?lang=' . rawurlencode($language), + 'separator_before' => true, + ), + array( + 'label' => t('app.user_management', 'User management'), + 'url' => '/users?lang=' . rawurlencode($language), + 'separator_before' => true, + ), + array( + 'label' => t('app.configuration', 'Configuration'), + 'url' => '/admin-config?lang=' . rawurlencode($language), + 'active' => true, + 'separator_before' => true, + ), + ), + 'user' => $currentUser, + 'user_prefix' => t('app.logged_in_as', 'Logged in as:'), + 'admin_label' => t('app.admin', 'Admin'), + 'language_label' => t('app.language', 'Language'), + 'language' => $language, + 'languages' => $headerLanguages, + 'language_action' => '/admin-config', + 'logout_action' => '/admin-config?lang=' . rawurlencode($language), + 'logout_label' => t('app.logout', 'Logout'), + 'message' => $message, + 'error' => $error, +)); +?> + +
+ +
+

+ +
+ + + + +
+ +

+ + + + + + + + + + + + + + + + + + + + + + +
KiB: bytes
KiB: bytes
config/racket.zip bytes
+ +, + + + + +
+
+ +
+

+ +
+ + +
+ + +
+ +

+ +

+ +

+ + 0): ?> + + + + + + + + + + + + + +
+ +

+ +
+
+ +
+
+ + diff --git a/config/apikey.cfg b/config/apikey.cfg new file mode 100644 index 0000000..c2c46ac --- /dev/null +++ b/config/apikey.cfg @@ -0,0 +1 @@ +flfadrdeyc.yvtpmoyjm.gthfkqbrf.kyhhvikcv diff --git a/config/base64-chunks.php b/config/base64-chunks.php new file mode 100644 index 0000000..e8e1d0e --- /dev/null +++ b/config/base64-chunks.php @@ -0,0 +1,12 @@ + 10240, + 'package_zip_max_base64_kb' => 2048, +); diff --git a/config/index.php b/config/index.php new file mode 100644 index 0000000..d99fbed --- /dev/null +++ b/config/index.php @@ -0,0 +1,2 @@ += $targetCount) { + return array_slice(array_keys($out), 0, $targetCount); + } + } + } + + return array_slice(array_keys($out), 0, $targetCount); +} + +$englishAdjectives = array( + 'agile', 'amber', 'brave', 'bright', 'bouncy', 'calm', + 'clever', 'cosmic', 'crisp', 'curious', 'dapper', 'daring', + 'dreamy', 'eager', 'electric', 'fancy', 'fizzy', 'gentle', + 'glowing', 'golden', 'happy', 'humble', 'jolly', 'jazzy', + 'kind', 'lively', 'lucky', 'mighty', 'mellow', 'nimble', + 'noble', 'peppy', 'plucky', 'polite', 'proud', 'quick', + 'quiet', 'radiant', 'rapid', 'shiny', 'silky', 'snappy', + 'sparkly', 'steady', 'sunny', 'tidy', 'tiny', 'valiant', + 'velvet', 'vivid', 'warm', 'witty', 'zesty', 'brisk', + 'cheery', 'deft', 'fresh', 'neat', 'smart', 'swift' +); + +$englishNouns = array( + 'anchor', 'banana', 'beacon', 'button', 'cactus', 'comet', + 'cookie', 'cupcake', 'disco', 'donut', 'engine', 'feather', + 'firefly', 'gizmo', 'harbor', 'honey', 'island', 'jigsaw', + 'kazoo', 'kernel', 'lantern', 'marble', 'meadow', 'muffin', + 'noodle', 'orbit', 'pancake', 'pebble', 'pickle', 'pocket', + 'puzzle', 'rainbow', 'rocket', 'sailboat', 'signal', 'spark', + 'sprocket', 'sunbeam', 'teacup', 'ticket', 'toffee', 'tulip', + 'velcro', 'waffle', 'widget', 'yo-yo', 'zipper', 'biscuit', + 'castle', 'doodle', 'ember', 'fiddle', 'garden', 'hub', + 'jacket', 'kettle', 'ladder', 'magnet', 'planet', 'ribbon' +); + +$dutchAdjectives = array( + 'aardig', 'actief', 'blij', 'blits', 'blozend', 'dapper', + 'deftig', 'echt', 'eenvoudig', 'fijn', 'fris', 'geestig', + 'geinig', 'gelukkig', 'gemoedelijk', 'geslepen', 'glad', 'goud', + 'grappig', 'groots', 'helder', 'hip', 'keurig', 'klein', + 'knap', 'koddig', 'koel', 'krachtig', 'kwiek', 'lief', + 'luchtig', 'moedig', 'netjes', 'nieuw', 'pienter', 'pittig', + 'prettig', 'rap', 'rustig', 'scherp', 'slim', 'snel', + 'speels', 'sprankel', 'sterk', 'stil', 'stoer', 'stralend', + 'tevreden', 'trots', 'vlot', 'vrolijk', 'warm', 'wijs', + 'zacht', 'zeker', 'zinnig', 'zuiver', 'zonnig', 'zot' +); + +$dutchNouns = array( + 'appel', 'anker', 'beker', 'bel', 'berg', 'bliksem', + 'bloem', 'boei', 'boog', 'boot', 'doos', 'druppel', + 'duin', 'fiets', 'fluit', 'fontein', 'gieter', 'glimmer', + 'grendel', 'haven', 'hoed', 'kaars', 'kaart', 'kasteel', + 'ketel', 'knikker', 'koek', 'kompas', 'kraan', 'kroon', + 'lamp', 'lint', 'magneet', 'molen', 'muis', 'munt', + 'noot', 'pannenkoek', 'parel', 'peer', 'pijp', 'planeet', + 'plank', 'potlood', 'raket', 'regenboog', 'ring', 'schoen', + 'sleutel', 'slinger', 'snor', 'ster', 'taart', 'theekop', + 'tulp', 'veer', 'vlam', 'wafel', 'zaklamp', 'zeil' +); + +$englishModifiers = array( + 'ultra', 'mega', 'mini', 'super', 'hyper', 'prime', 'fresh', 'solid' +); + +$dutchModifiers = array( + 'super', 'mega', 'mini', 'opper', 'top', 'fris', 'puur', 'mooi' +); + +return array( + 'en' => array( + 'adjective' => expand_next_token_words($englishAdjectives, $englishModifiers, 500), + 'noun' => expand_next_token_words($englishNouns, $englishModifiers, 500), + ), + 'nl' => array( + 'adjective' => expand_next_token_words($dutchAdjectives, $dutchModifiers, 500), + 'noun' => expand_next_token_words($dutchNouns, $dutchModifiers, 500), + ), +); diff --git a/config/racket.zip b/config/racket.zip new file mode 100644 index 0000000..ca936f8 Binary files /dev/null and b/config/racket.zip differ diff --git a/gitfetcher.php b/gitfetcher.php new file mode 100644 index 0000000..f94fde8 --- /dev/null +++ b/gitfetcher.php @@ -0,0 +1,578 @@ +.json; + * 4. als de SHA gelijk is en data/.zip bestaat: niets downloaden; + * 5. anders: haal de repository-zip op en sla op als data/.zip. + * + * Ondersteund: + * - github.com + * - git.dijkewijk.nl Gitea + * - codeberg.org Gitea + * + * GitHub: + * - branch-SHA via /repos/{owner}/{repo}/branches/{branch} + * - zip via /repos/{owner}/{repo}/zipball/{ref} + * + * Gitea/Codeberg: + * - repository-info via /api/v1/repos/{owner}/{repo} + * - branch-SHA via /api/v1/repos/{owner}/{repo}/branches/{branch} + * - zip via /owner/repo/archive/{ref}.zip + */ + +class GitFetcherException extends Exception +{ +} + +class GitFetcher +{ + private $dataDir; + private $timeout; + private $connectTimeout; + private $userAgent; + private $tokensByHost; + + public function __construct($options = array()) + { + $this->dataDir = isset($options['data_dir']) + ? rtrim((string)$options['data_dir'], '/') + : __DIR__ . '/data'; + + $this->timeout = isset($options['timeout']) ? (int)$options['timeout'] : 180; + $this->connectTimeout = isset($options['connect_timeout']) ? (int)$options['connect_timeout'] : 20; + $this->userAgent = isset($options['user_agent']) + ? (string)$options['user_agent'] + : 'rktsndbx-gitfetcher/1.0'; + + $this->tokensByHost = isset($options['tokens']) && is_array($options['tokens']) + ? $options['tokens'] + : array(); + } + + /* + * Hoofdentry voor jouw package-route. + * + * Voorbeeld: + * + * $gf = new GitFetcher(); + * $info = $gf->ensurePackageZip( + * 'html-parsing', + * 'https://github.com/soegaard/html-parsing' + * ); + * + * Resultaat: + * + * data/html-parsing.zip + * data/html-parsing.json + */ + public function ensurePackageZip($packageName, $repoUrl) + { + $packageName = $this->safePackageName($packageName); + $repo = $this->parseRepositoryUrl($repoUrl); + $head = $this->currentHead($repo); + + $zipFile = $this->packageZipFile($packageName); + $metaFile = $this->packageMetaFile($packageName); + + $old = $this->readJsonFile($metaFile); + + if (is_file($zipFile) && + is_readable($zipFile) && + is_array($old) && + isset($old['head_sha']) && + $old['head_sha'] === $head['head_sha'] && + isset($old['repo_url']) && + $old['repo_url'] === $repoUrl) { + + return array( + 'status' => 'cached', + 'package' => $packageName, + 'repo_url' => $repoUrl, + 'host' => $repo['host'], + 'owner' => $repo['owner'], + 'repo' => $repo['repo'], + 'default_branch' => $head['default_branch'], + 'head_sha' => $head['head_sha'], + 'zip_file' => $zipFile, + 'meta_file' => $metaFile, + 'zip_bytes' => filesize($zipFile), + 'zip_sha256' => hash_file('sha256', $zipFile), + ); + } + + $archive = $this->downloadArchiveForHead($repo, $head); + $this->ensureDataDir(); + + $tmpZip = $zipFile . '.tmp.' . getmypid(); + $tmpMeta = $metaFile . '.tmp.' . getmypid(); + + if (file_put_contents($tmpZip, $archive['bytes'], LOCK_EX) === false) { + @unlink($tmpZip); + throw new GitFetcherException('Kan tijdelijke zip niet schrijven: ' . $tmpZip); + } + + $meta = array( + 'package' => $packageName, + 'repo_url' => $repoUrl, + 'host' => $repo['host'], + 'owner' => $repo['owner'], + 'repo' => $repo['repo'], + 'default_branch' => $head['default_branch'], + 'head_sha' => $head['head_sha'], + 'archive_url' => $archive['archive_url'], + 'zip_file' => $zipFile, + 'zip_bytes' => strlen($archive['bytes']), + 'zip_sha256' => hash('sha256', $archive['bytes']), + 'updated_at' => gmdate('c'), + ); + + $json = json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if ($json === false || file_put_contents($tmpMeta, $json . "\n", LOCK_EX) === false) { + @unlink($tmpZip); + @unlink($tmpMeta); + throw new GitFetcherException('Kan tijdelijke metadata niet schrijven: ' . $tmpMeta); + } + + if (!rename($tmpZip, $zipFile)) { + @unlink($tmpZip); + @unlink($tmpMeta); + throw new GitFetcherException('Kan zip niet plaatsen: ' . $zipFile); + } + + if (!rename($tmpMeta, $metaFile)) { + @unlink($tmpMeta); + throw new GitFetcherException('Kan metadata niet plaatsen: ' . $metaFile); + } + + $meta['status'] = 'downloaded'; + return $meta; + } + + /* + * Alleen controleren, zonder zip te downloaden. + */ + public function packageZipIsCurrent($packageName, $repoUrl) + { + $packageName = $this->safePackageName($packageName); + $repo = $this->parseRepositoryUrl($repoUrl); + $head = $this->currentHead($repo); + + $zipFile = $this->packageZipFile($packageName); + $metaFile = $this->packageMetaFile($packageName); + $old = $this->readJsonFile($metaFile); + + return is_file($zipFile) && + is_readable($zipFile) && + is_array($old) && + isset($old['repo_url']) && + $old['repo_url'] === $repoUrl && + isset($old['head_sha']) && + $old['head_sha'] === $head['head_sha']; + } + + /* + * Bepaal default branch + huidige commit SHA. + */ + public function currentHeadForRepositoryUrl($repoUrl) + { + $repo = $this->parseRepositoryUrl($repoUrl); + return $this->currentHead($repo); + } + + private function safePackageName($packageName) + { + $packageName = (string)$packageName; + + if (!preg_match('/^[A-Za-z0-9_.+-]+$/', $packageName)) { + throw new GitFetcherException('Ongeldige package naam: ' . $packageName); + } + + return $packageName; + } + + private function packageZipFile($packageName) + { + return $this->dataDir . '/' . $packageName . '.zip'; + } + + private function packageMetaFile($packageName) + { + return $this->dataDir . '/' . $packageName . '.json'; + } + + private function ensureDataDir() + { + if (!is_dir($this->dataDir)) { + if (!mkdir($this->dataDir, 0755, true)) { + throw new GitFetcherException('Kan data directory niet maken: ' . $this->dataDir); + } + } + + if (!is_writable($this->dataDir)) { + throw new GitFetcherException('Data directory is niet schrijfbaar: ' . $this->dataDir); + } + } + + private function readJsonFile($file) + { + if (!is_file($file) || !is_readable($file)) { + return null; + } + + $raw = file_get_contents($file); + + if ($raw === false || $raw === '') { + return null; + } + + $json = json_decode($raw, true); + + return is_array($json) ? $json : null; + } + + public function parseRepositoryUrl($repoUrl) + { + $repoUrl = trim((string)$repoUrl); + + /* + * SSH-vorm: + * git@github.com:owner/repo.git + */ + if (preg_match('/^git@([^:]+):(.+)$/', $repoUrl, $m)) { + return $this->parseHostAndPath(strtolower($m[1]), trim($m[2], '/'), $repoUrl); + } + + if (strpos($repoUrl, 'git+https://') === 0) { + $repoUrl = 'https://' . substr($repoUrl, strlen('git+https://')); + } elseif (strpos($repoUrl, 'git+http://') === 0) { + $repoUrl = 'http://' . substr($repoUrl, strlen('git+http://')); + } + + $p = parse_url($repoUrl); + + if ($p === false || empty($p['host']) || empty($p['path'])) { + throw new GitFetcherException('Ongeldige repository URL: ' . $repoUrl); + } + + return $this->parseHostAndPath( + strtolower($p['host']), + trim($p['path'], '/'), + $repoUrl + ); + } + + private function parseHostAndPath($host, $path, $originalUrl) + { + $kinds = array( + 'github.com' => 'github', + 'git.dijkewijk.nl' => 'gitea', + 'codeberg.org' => 'gitea', + ); + + if (!isset($kinds[$host])) { + throw new GitFetcherException('Niet-ondersteunde git host: ' . $host); + } + + if (substr($path, -4) === '.git') { + $path = substr($path, 0, -4); + } + + $bits = explode('/', $path); + + if (count($bits) < 2 || $bits[0] === '' || $bits[1] === '') { + throw new GitFetcherException('Kan owner/repo niet bepalen uit URL: ' . $originalUrl); + } + + return array( + 'kind' => $kinds[$host], + 'host' => $host, + 'owner' => $bits[0], + 'repo' => $bits[1], + ); + } + + private function currentHead($repo) + { + if ($repo['kind'] === 'github') { + return $this->githubCurrentHead($repo); + } + + return $this->giteaCurrentHead($repo); + } + + private function githubCurrentHead($repo) + { + $repoApi = + 'https://api.github.com/repos/' . + rawurlencode($repo['owner']) . '/' . + rawurlencode($repo['repo']); + + $repoJson = $this->httpGetJson($repoApi, $repo['host']); + + if (empty($repoJson['default_branch']) || !is_string($repoJson['default_branch'])) { + throw new GitFetcherException('GitHub API gaf geen default_branch.'); + } + + $branch = $repoJson['default_branch']; + + $branchApi = + 'https://api.github.com/repos/' . + rawurlencode($repo['owner']) . '/' . + rawurlencode($repo['repo']) . + '/branches/' . + rawurlencode($branch); + + $branchJson = $this->httpGetJson($branchApi, $repo['host']); + + if (empty($branchJson['commit']['sha']) || !is_string($branchJson['commit']['sha'])) { + throw new GitFetcherException('GitHub API gaf geen branch commit SHA.'); + } + + return array( + 'default_branch' => $branch, + 'head_sha' => $branchJson['commit']['sha'], + ); + } + + private function giteaCurrentHead($repo) + { + $repoApi = + 'https://' . $repo['host'] . + '/api/v1/repos/' . + rawurlencode($repo['owner']) . '/' . + rawurlencode($repo['repo']); + + $repoJson = $this->httpGetJson($repoApi, $repo['host']); + + $branch = null; + + if (!empty($repoJson['default_branch']) && is_string($repoJson['default_branch'])) { + $branch = $repoJson['default_branch']; + } elseif (!empty($repoJson['default_branch_name']) && is_string($repoJson['default_branch_name'])) { + $branch = $repoJson['default_branch_name']; + } + + if ($branch === null || $branch === '') { + $branch = 'main'; + } + + $branchApi = + 'https://' . $repo['host'] . + '/api/v1/repos/' . + rawurlencode($repo['owner']) . '/' . + rawurlencode($repo['repo']) . + '/branches/' . + rawurlencode($branch); + + $branchJson = $this->httpGetJson($branchApi, $repo['host']); + $sha = $this->extractGiteaBranchSha($branchJson); + + if ($sha === null || $sha === '') { + throw new GitFetcherException('Gitea API gaf geen branch commit SHA.'); + } + + return array( + 'default_branch' => $branch, + 'head_sha' => $sha, + ); + } + + private function extractGiteaBranchSha($branchJson) + { + /* + * Gitea/Forgejo varianten komen in de praktijk voor als: + * commit.id + * commit.sha + * commit.commit.id + */ + $paths = array( + array('commit', 'id'), + array('commit', 'sha'), + array('commit', 'commit', 'id'), + ); + + foreach ($paths as $path) { + $v = $branchJson; + + foreach ($path as $k) { + if (!is_array($v) || !array_key_exists($k, $v)) { + $v = null; + break; + } + + $v = $v[$k]; + } + + if (is_string($v) && preg_match('/^[0-9a-f]{7,40}$/i', $v)) { + return $v; + } + } + + return null; + } + + private function downloadArchiveForHead($repo, $head) + { + /* + * Bij voorkeur downloaden we op exacte SHA, niet op branchnaam. + * Dan hoort de zip exact bij de SHA die we in metadata opslaan. + */ + $shaUrl = $this->archiveUrl($repo, $head['head_sha']); + $branchUrl = $this->archiveUrl($repo, $head['default_branch']); + + try { + $bytes = $this->httpGet($shaUrl, $repo['host'], true); + return array( + 'archive_url' => $shaUrl, + 'bytes' => $bytes, + ); + } catch (GitFetcherException $e) { + /* + * Sommige Gitea/Forgejo instanties accepteren branch/tag ref wel + * maar commit-SHA niet in archive/.zip. Dan fallback naar + * branch. De SHA-check blijft alsnog gebaseerd op de API. + */ + $bytes = $this->httpGet($branchUrl, $repo['host'], true); + return array( + 'archive_url' => $branchUrl, + 'bytes' => $bytes, + ); + } + } + + private function archiveUrl($repo, $ref) + { + if ($ref === '') { + throw new GitFetcherException('Lege archive ref.'); + } + + if ($repo['kind'] === 'github') { + return + 'https://api.github.com/repos/' . + rawurlencode($repo['owner']) . '/' . + rawurlencode($repo['repo']) . + '/zipball/' . + rawurlencode($ref); + } + + return + 'https://' . $repo['host'] . '/' . + rawurlencode($repo['owner']) . '/' . + rawurlencode($repo['repo']) . + '/archive/' . + rawurlencode($ref) . + '.zip'; + } + + private function httpGetJson($url, $host) + { + $body = $this->httpGet($url, $host, true); + $json = json_decode($body, true); + + if (!is_array($json)) { + throw new GitFetcherException('Response is geen JSON: ' . $url); + } + + return $json; + } + + private function httpGet($url, $host, $followRedirects) + { + if (!function_exists('curl_init')) { + return $this->httpGetWithoutCurl($url, $host); + } + + $headers = array( + 'User-Agent: ' . $this->userAgent, + ); + + if ($host === 'github.com') { + $headers[] = 'Accept: application/vnd.github+json'; + $headers[] = 'X-GitHub-Api-Version: 2022-11-28'; + } + + if (!empty($this->tokensByHost[$host])) { + if ($host === 'github.com') { + $headers[] = 'Authorization: Bearer ' . $this->tokensByHost[$host]; + } else { + $headers[] = 'Authorization: token ' . $this->tokensByHost[$host]; + } + } + + $ch = curl_init($url); + + curl_setopt_array($ch, array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => $followRedirects ? true : false, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_USERAGENT => $this->userAgent, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_FAILONERROR => false, + )); + + $body = curl_exec($ch); + + if ($body === false) { + $err = curl_error($ch); + curl_close($ch); + throw new GitFetcherException('HTTP request mislukt: ' . $err . ' url=' . $url); + } + + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status < 200 || $status >= 300) { + throw new GitFetcherException( + 'HTTP status ' . $status . ' voor ' . $url . "\n" . + substr((string)$body, 0, 500) + ); + } + + return $body; + } + + private function httpGetWithoutCurl($url, $host) + { + $headers = array( + 'User-Agent: ' . $this->userAgent, + ); + + if ($host === 'github.com') { + $headers[] = 'Accept: application/vnd.github+json'; + $headers[] = 'X-GitHub-Api-Version: 2022-11-28'; + } + + if (!empty($this->tokensByHost[$host])) { + if ($host === 'github.com') { + $headers[] = 'Authorization: Bearer ' . $this->tokensByHost[$host]; + } else { + $headers[] = 'Authorization: token ' . $this->tokensByHost[$host]; + } + } + + $ctx = stream_context_create(array( + 'http' => array( + 'method' => 'GET', + 'timeout' => $this->timeout, + 'ignore_errors' => true, + 'header' => implode("\r\n", $headers) . "\r\n", + ), + )); + + $body = @file_get_contents($url, false, $ctx); + + if ($body === false) { + throw new GitFetcherException('HTTP request mislukt: ' . $url); + } + + return $body; + } +} \ No newline at end of file diff --git a/header.php b/header.php new file mode 100644 index 0000000..6fa928b --- /dev/null +++ b/header.php @@ -0,0 +1,94 @@ + + + translate($key, $language, $fallback); +} + +function current_scheme() +{ + if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0])); + } + + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +} + +function current_host() +{ + return $_SERVER['HTTP_HOST'] ?? 'localhost'; +} + +function bootstrap_link_for_token($token) +{ + return current_scheme() . '://' . current_host() . + '/bootstrap-racket?next=' . rawurlencode($token); +} + +function post_value($name, $default = '') +{ + return $_POST[$name] ?? $default; +} + +function post_bool($name) +{ + return isset($_POST[$name]) && $_POST[$name] === '1'; +} + +function create_next_token($tokens, $ttlSeconds, $meta) +{ + /* + * Ondersteunt zowel: + * create($ttl) + * als: + * create($ttl, $meta) + */ + $rm = new ReflectionMethod($tokens, 'create'); + + if ($rm->getNumberOfParameters() >= 2) { + return $tokens->create($ttlSeconds, $meta); + } + + return $tokens->create($ttlSeconds); +} + +function clamp_bootstrap_ttl_minutes($ttlMinutes) +{ + $ttlMinutes = (int)$ttlMinutes; + + if ($ttlMinutes < BOOTSTRAP_TTL_MIN_MINUTES) { + return BOOTSTRAP_TTL_MIN_MINUTES; + } + + if ($ttlMinutes > BOOTSTRAP_TTL_MAX_MINUTES) { + return BOOTSTRAP_TTL_MAX_MINUTES; + } + + return $ttlMinutes; +} + +function resolve_user_language($userSettings, $userId, $allowedLanguages) +{ + $language = isset($_GET['lang']) + ? (string)$_GET['lang'] + : (string)$userSettings->get($userId, 'language', 'en'); + + if (!in_array($language, $allowedLanguages, true)) { + $language = 'en'; + } + + $userSettings->set($userId, 'language', $language); + + return $language; +} + +$currentUser = $auth->currentUser(); + +if ($currentUser === null) { + header('Location: /login.php'); + exit; +} + +$language = resolve_user_language( + $userSettings, + $currentUser->id(), + $languageStore->supportedLanguages() +); + +$languageStore->seedDefaults(array( + 'app.title' => array('en' => 'Racket sandbox', 'nl' => 'Racket sandbox'), + 'app.manage_prompts' => array('en' => 'Manage prompts', 'nl' => 'Prompts beheren'), + 'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'), + 'app.language' => array('en' => 'Language', 'nl' => 'Taal'), + 'app.logged_in_as' => array('en' => 'Logged in as:', 'nl' => 'Ingelogd als:'), + 'app.admin' => array('en' => 'admin', 'nl' => 'admin'), + 'app.bootstrap_link' => array('en' => 'Bootstrap link', 'nl' => 'Bootstraplink'), + 'app.generate_bootstrap_link' => array('en' => 'Generate bootstrap link', 'nl' => 'Bootstraplink genereren'), + 'app.ttl_minutes' => array('en' => 'TTL in minutes', 'nl' => 'TTL in minuten'), + 'app.ttl_range_help' => array('en' => 'Allowed range: 30 minutes to 8 hours.', 'nl' => 'Toegestaan bereik: 30 minuten tot 8 uur.'), + 'app.generated_link' => array('en' => 'Generated link', 'nl' => 'Gegenereerde link'), + 'app.copy' => array('en' => 'Copy', 'nl' => 'Kopieer'), + 'app.copied' => array('en' => 'Copied', 'nl' => 'Gekopieerd'), + 'app.generated_link_help' => array( + 'en' => 'Give this link to the AI agent. The agent should start from this link and then only follow links from the generated HTML pages.', + 'nl' => 'Geef deze link aan de AI-agent. De agent moet vanaf deze link starten en daarna alleen links volgen vanuit de gegenereerde HTML-paginas.' + ), + 'app.select_prompt' => array('en' => 'Select prompt', 'nl' => 'Prompt selecteren'), + 'app.copy_full_prompt' => array('en' => 'Copy full prompt', 'nl' => 'Volledige prompt kopieren'), + 'app.bootstrap_prompt_help' => array( + 'en' => 'Choose one of your prompts. The placeholder {{bootstrap-racket-link}} is replaced by the generated bootstrap link.', + 'nl' => 'Kies een van je prompts. De placeholder {{bootstrap-racket-link}} wordt vervangen door de gegenereerde bootstraplink.' + ), + 'app.no_bootstrap_prompts' => array( + 'en' => 'No personal prompts are available for this language. Copy a default prompt first from prompt management.', + 'nl' => 'Er zijn geen persoonlijke prompts beschikbaar voor deze taal. Kopieer eerst een standaardprompt vanuit promptbeheer.' + ), + 'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'), + 'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'), + 'app.user_management_help' => array( + 'en' => 'Users are registered manually by email address, full name and password. This page only manages existing users.', + 'nl' => 'Gebruikers worden handmatig geregistreerd met e-mailadres, volledige naam en wachtwoord. Deze pagina beheert alleen bestaande gebruikers.' + ), + 'app.id' => array('en' => 'ID', 'nl' => 'ID'), + 'app.full_name' => array('en' => 'Full name', 'nl' => 'Volledige naam'), + 'app.email' => array('en' => 'Email', 'nl' => 'E-mail'), + 'app.enabled' => array('en' => 'Enabled', 'nl' => 'Ingeschakeld'), + 'app.created' => array('en' => 'Created', 'nl' => 'Gemaakt'), + 'app.last_login' => array('en' => 'Last login', 'nl' => 'Laatste login'), + 'app.actions' => array('en' => 'Actions', 'nl' => 'Acties'), + 'app.yes' => array('en' => 'yes', 'nl' => 'ja'), + 'app.no' => array('en' => 'no', 'nl' => 'nee'), + 'app.save_flags' => array('en' => 'Save flags', 'nl' => 'Vlaggen opslaan'), + 'app.new_password' => array('en' => 'New password', 'nl' => 'Nieuw wachtwoord'), + 'app.change_password' => array('en' => 'Change password', 'nl' => 'Wachtwoord wijzigen'), + 'app.delete_user' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'), + 'app.delete_user_confirm' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'), + 'app.cannot_delete_self' => array('en' => 'You cannot delete your own account.', 'nl' => 'Je kunt je eigen account niet verwijderen.'), + 'app.bootstrap_link_issued' => array('en' => 'Bootstrap link issued.', 'nl' => 'Bootstraplink aangemaakt.'), + 'app.password_changed_for' => array('en' => 'Password changed for:', 'nl' => 'Wachtwoord gewijzigd voor:'), + 'app.user_flags_updated' => array('en' => 'User flags updated.', 'nl' => 'Gebruikersvlaggen bijgewerkt.'), + 'app.user_deleted' => array('en' => 'User deleted.', 'nl' => 'Gebruiker verwijderd.'), + 'app.admin_rights_required' => array('en' => 'Admin rights required.', 'nl' => 'Adminrechten vereist.'), + 'app.cannot_remove_own_admin' => array('en' => 'You cannot remove your own admin rights.', 'nl' => 'Je kunt je eigen adminrechten niet verwijderen.'), + 'app.cannot_disable_self' => array('en' => 'You cannot disable your own account.', 'nl' => 'Je kunt je eigen account niet uitschakelen.'), + 'app.unknown_action' => array('en' => 'Unknown action:', 'nl' => 'Onbekende actie:'), +)); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = post_value('action'); + + try { + if ($action === 'logout') { + $auth->logout(); + header('Location: /login.php'); + exit; + } + + $currentUser = $auth->currentUser(); + + if ($currentUser === null) { + header('Location: /login.php'); + exit; + } + + if ($action === 'issue_bootstrap') { + $ttlMinutes = clamp_bootstrap_ttl_minutes( + post_value('ttl_minutes', (string)BOOTSTRAP_TTL_DEFAULT_MINUTES) + ); + + $userSettings->set($currentUser->id(), 'bootstrap_ttl_minutes', (string)$ttlMinutes); + + $token = create_next_token( + $tokens, + $ttlMinutes * 60, + array( + 'user_id' => $currentUser->id(), + 'email' => $currentUser->email(), + 'full_name' => $currentUser->fullName(), + 'purpose' => 'racket-bootstrap', + ) + ); + + $issuedLink = bootstrap_link_for_token($token); + $message = t('app.bootstrap_link_issued', 'Bootstrap link issued.'); + } else { + if (!$currentUser->isAdmin()) { + throw new Exception(t('app.admin_rights_required', 'Admin rights required.')); + } + + if ($action === 'set_password') { + $email = trim(post_value('email')); + $password = (string)post_value('password'); + + $auth->setPassword($email, $password); + $message = t('app.password_changed_for', 'Password changed for:') . ' ' . $email; + } elseif ($action === 'set_flags') { + $userId = (int)post_value('user_id'); + $isAdmin = post_bool('is_admin'); + $isEnabled = post_bool('is_enabled'); + + if ($userId === $currentUser->id() && !$isAdmin) { + throw new Exception(t('app.cannot_remove_own_admin', 'You cannot remove your own admin rights.')); + } + + if ($userId === $currentUser->id() && !$isEnabled) { + throw new Exception(t('app.cannot_disable_self', 'You cannot disable your own account.')); + } + + $auth->setAdmin($userId, $isAdmin); + $auth->setEnabled($userId, $isEnabled); + $message = t('app.user_flags_updated', 'User flags updated.'); + } elseif ($action === 'delete_user') { + $userId = (int)post_value('user_id'); + + if ($userId === $currentUser->id()) { + throw new Exception('You cannot delete your own account.'); + } + + $auth->deleteUser($userId); + $message = t('app.user_deleted', 'User deleted.'); + } elseif ($action !== '') { + throw new Exception(t('app.unknown_action', 'Unknown action:') . ' ' . $action); + } + } + } catch (Throwable $e) { + $error = $e->getMessage(); + } +} + +$currentUser = $auth->currentUser(); + +if ($currentUser === null) { + header('Location: /login.php'); + exit; +} + +$bootstrapTtlMinutes = clamp_bootstrap_ttl_minutes( + $userSettings->get( + $currentUser->id(), + 'bootstrap_ttl_minutes', + (string)BOOTSTRAP_TTL_DEFAULT_MINUTES + ) +); + +foreach ($promptStore->listPrompts($currentUser->id(), $language) as $prompt) { + $bootstrapPrompts[] = array( + 'id' => $prompt->id(), + 'name' => $prompt->name(), + 'content' => $prompt->content(), + ); +} + +$headerLanguages = array(); + +foreach ($languageStore->supportedLanguages() as $lang) { + $headerLanguages[$lang] = $languageStore->languageLabel($lang); +} + +$headerNavItems = array( + array( + 'label' => t('app.manage_prompts', 'Manage prompts'), + 'url' => '/prompts?lang=' . rawurlencode($language), + ), +); + +if ($currentUser->isAdmin()) { + $headerNavItems[] = array( + 'label' => t('app.user_management', 'User management'), + 'url' => '/users?lang=' . rawurlencode($language), + 'separator_before' => true, + ); + $headerNavItems[] = array( + 'label' => t('app.configuration', 'Configuration'), + 'url' => '/admin-config?lang=' . rawurlencode($language), + 'separator_before' => true, + ); +} + +header('Content-Type: text/html; charset=utf-8'); +?> + + + + +<?= h(t('app.title', 'Racket sandbox')) ?> + + + + +
+ + t('app.title', 'Racket sandbox'), + 'nav_items' => $headerNavItems, + 'user' => $currentUser, + 'user_prefix' => t('app.logged_in_as', 'Logged in as:'), + 'admin_label' => t('app.admin', 'admin'), + 'language_label' => t('app.language', 'Language'), + 'language' => $language, + 'languages' => $headerLanguages, + 'language_action' => '/', + 'logout_action' => '/?lang=' . rawurlencode($language), + 'logout_label' => t('app.logout', 'Logout'), + 'message' => $message, + 'error' => $error, +)); +?> + +
+ +
+

+ +
+ + + + +
+
+ +
+ +
+ + + + + diff --git a/languagestore.php b/languagestore.php new file mode 100644 index 0000000..26e8dbe --- /dev/null +++ b/languagestore.php @@ -0,0 +1,205 @@ +fallbackLanguage = $this->safeLanguage($fallbackLanguage); + + $this->db = new PDO('sqlite:' . $dbFile); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->db->exec('PRAGMA journal_mode = WAL'); + $this->db->exec('PRAGMA busy_timeout = 5000'); + + $this->init(); + } + + private function init() + { + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS app_translations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + translation_key TEXT NOT NULL, + language TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(translation_key, language) + )' + ); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_app_translations_language + ON app_translations(language, translation_key)' + ); + } + + public function translate($key, $language, $fallback = null) + { + $key = $this->safeKey($key); + $language = $this->safeLanguage($language); + + $text = $this->findText($key, $language); + + if ($text !== null) { + return $text; + } + + if ($language !== $this->fallbackLanguage) { + $text = $this->findText($key, $this->fallbackLanguage); + + if ($text !== null) { + return $text; + } + } + + return $fallback !== null ? (string)$fallback : $key; + } + + public function setTranslation($key, $language, $text) + { + $key = $this->safeKey($key); + $language = $this->safeLanguage($language); + $text = (string)$text; + $now = time(); + + $stmt = $this->db->prepare( + 'INSERT INTO app_translations + (translation_key, language, text, created_at, updated_at) + VALUES + (:translation_key, :language, :text, :created_at, :updated_at) + ON CONFLICT(translation_key, language) + DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at' + ); + + $stmt->execute(array( + ':translation_key' => $key, + ':language' => $language, + ':text' => $text, + ':created_at' => $now, + ':updated_at' => $now, + )); + } + + public function seedDefaults($translations) + { + foreach ($translations as $key => $byLanguage) { + foreach ($byLanguage as $language => $text) { + if (!$this->hasTranslation($key, $language)) { + $this->setTranslation($key, $language, $text); + } + } + } + } + + public function listTranslations($language = null) + { + if ($language !== null && $language !== '') { + $stmt = $this->db->prepare( + 'SELECT translation_key, language, text, created_at, updated_at + FROM app_translations + WHERE language = :language + ORDER BY translation_key' + ); + $stmt->execute(array(':language' => $this->safeLanguage($language))); + } else { + $stmt = $this->db->query( + 'SELECT translation_key, language, text, created_at, updated_at + FROM app_translations + ORDER BY translation_key, language' + ); + } + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function supportedLanguages() + { + return array('en', 'nl'); + } + + public function languageLabel($language) + { + if ($language === 'nl') { + return 'Nederlands'; + } + + if ($language === 'en') { + return 'English'; + } + + return $language; + } + + private function findText($key, $language) + { + $stmt = $this->db->prepare( + 'SELECT text + FROM app_translations + WHERE translation_key = :translation_key + AND language = :language' + ); + $stmt->execute(array( + ':translation_key' => $key, + ':language' => $language, + )); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? (string)$row['text'] : null; + } + + private function hasTranslation($key, $language) + { + return $this->findText($this->safeKey($key), $this->safeLanguage($language)) !== null; + } + + private function safeKey($key) + { + $key = trim((string)$key); + + if ($key === '') { + throw new LanguageStoreException('Translation key is required.'); + } + + if (strlen($key) > 160) { + throw new LanguageStoreException('Translation key is too long.'); + } + + return $key; + } + + private function safeLanguage($language) + { + $language = strtolower(trim((string)$language)); + + if (!preg_match('/^[a-z][a-z0-9_-]{1,15}$/', $language)) { + throw new LanguageStoreException('Invalid language code.'); + } + + return $language; + } +} diff --git a/lib/catalog-http.php b/lib/catalog-http.php new file mode 100644 index 0000000..6372d5e --- /dev/null +++ b/lib/catalog-http.php @@ -0,0 +1,228 @@ + true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 20, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_USERAGENT => $userAgent, + CURLOPT_FAILONERROR => false, + CURLOPT_HTTPHEADER => $requestHeaders, + CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$responseHeaders) { + $pos = strpos($line, ':'); + + if ($pos !== false) { + $name = catalog_http_normalize_header_name(substr($line, 0, $pos)); + $value = trim(substr($line, $pos + 1)); + $responseHeaders[$name] = $value; + } + + return strlen($line); + }, + )); + + $body = curl_exec($ch); + + if ($body === false) { + $err = curl_error($ch); + curl_close($ch); + throw new RuntimeException('Catalogus ophalen mislukt: ' . $err); + } + + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 304 && ($status < 200 || $status >= 300)) { + throw new RuntimeException('Catalogus gaf HTTP status ' . $status . ' voor ' . $url); + } + + return array( + 'status' => $status, + 'headers' => $responseHeaders, + 'body' => $body, + ); + } + + $headerLines = array('User-Agent: ' . $userAgent); + + foreach ($requestHeaders as $header) { + $headerLines[] = $header; + } + + $ctx = stream_context_create(array( + 'http' => array( + 'method' => 'GET', + 'timeout' => $timeout, + 'header' => implode("\r\n", $headerLines) . "\r\n", + 'ignore_errors' => true, + ), + )); + + $body = @file_get_contents($url, false, $ctx); + + if ($body === false) { + throw new RuntimeException('Catalogus ophalen mislukt: ' . $url); + } + + $status = 0; + $responseHeaders = array(); + $rawHeaders = isset($http_response_header) && is_array($http_response_header) + ? $http_response_header + : array(); + + foreach ($rawHeaders as $line) { + if (preg_match('/^HTTP\/\S+\s+(\d+)/', $line, $m)) { + $status = (int)$m[1]; + continue; + } + + $pos = strpos($line, ':'); + + if ($pos !== false) { + $name = catalog_http_normalize_header_name(substr($line, 0, $pos)); + $value = trim(substr($line, $pos + 1)); + $responseHeaders[$name] = $value; + } + } + + if ($status !== 304 && ($status < 200 || $status >= 300)) { + throw new RuntimeException('Catalogus gaf HTTP status ' . $status . ' voor ' . $url); + } + + return array( + 'status' => $status, + 'headers' => $responseHeaders, + 'body' => $body, + ); +} + +function catalog_http_fetch_cached($url, $cacheFile, $metaFile, $ttl, $userAgent, $timeout = 180) +{ + catalog_http_ensure_dir(dirname($cacheFile)); + + if (is_file($cacheFile)) { + $mtime = filemtime($cacheFile); + + if ($mtime !== false && $mtime >= time() - $ttl) { + $data = file_get_contents($cacheFile); + + if ($data !== false && $data !== '') { + return $data; + } + } + } + + $meta = catalog_http_read_meta($metaFile); + $requestHeaders = is_file($cacheFile) ? catalog_http_validator_headers($meta) : array(); + $response = catalog_http_fetch($url, $requestHeaders, $userAgent, $timeout); + + if ($response['status'] === 304) { + $data = file_get_contents($cacheFile); + + if ($data !== false && $data !== '') { + touch($cacheFile); + $meta['checked_at'] = time(); + catalog_http_write_meta($metaFile, $meta); + return $data; + } + } + + $data = $response['body']; + + if (file_put_contents($cacheFile, $data, LOCK_EX) === false) { + throw new RuntimeException('Kan catalogus-cache niet schrijven: ' . $cacheFile); + } + + $headers = $response['headers']; + $now = time(); + $meta = array( + 'url' => $url, + 'fetched_at' => $now, + 'checked_at' => $now, + ); + + if (!empty($headers['etag'])) { + $meta['etag'] = $headers['etag']; + } + + if (!empty($headers['last-modified'])) { + $meta['last_modified'] = $headers['last-modified']; + } + + catalog_http_write_meta($metaFile, $meta); + + return $data; +} diff --git a/lib/racket-data.php b/lib/racket-data.php new file mode 100644 index 0000000..d7b8f48 --- /dev/null +++ b/lib/racket-data.php @@ -0,0 +1,317 @@ += $n) { + break; + } + + $i++; + $e = $s[$i]; + + if ($e === 'n') { + $out .= "\n"; + } elseif ($e === 'r') { + $out .= "\r"; + } elseif ($e === 't') { + $out .= "\t"; + } else { + $out .= $e; + } + } + + return $out; +} + +function rktd_read_string($s, &$i) +{ + $n = strlen($s); + + if ($i >= $n || $s[$i] !== '"') { + return null; + } + + $i++; + $out = ''; + + while ($i < $n) { + $c = $s[$i]; + $i++; + + if ($c === '"') { + return $out; + } + + if ($c === '\\') { + if ($i >= $n) { + break; + } + + $e = $s[$i]; + $i++; + + if ($e === 'n') { + $out .= "\n"; + } elseif ($e === 'r') { + $out .= "\r"; + } elseif ($e === 't') { + $out .= "\t"; + } else { + $out .= $e; + } + } else { + $out .= $c; + } + } + + return null; +} + +function rktd_skip_space($s, &$i) +{ + $n = strlen($s); + + while ($i < $n) { + $c = $s[$i]; + + if ($c === ';') { + while ($i < $n && $s[$i] !== "\n") { + $i++; + } + continue; + } + + if ($c === ' ' || $c === "\t" || $c === "\r" || $c === "\n") { + $i++; + continue; + } + + break; + } +} + +function rktd_skip_string($s, &$i) +{ + $dummy = rktd_read_string($s, $i); + return $dummy !== null; +} + +function rktd_skip_atom($s, &$i) +{ + $n = strlen($s); + + while ($i < $n) { + $c = $s[$i]; + + if ($c === ' ' || $c === "\t" || $c === "\r" || $c === "\n" || + $c === '(' || $c === ')' || $c === '"' || $c === ';') { + break; + } + + $i++; + } +} + +function rktd_skip_expr($s, &$i) +{ + $n = strlen($s); + + rktd_skip_space($s, $i); + + if ($i >= $n) { + return; + } + + $c = $s[$i]; + + if ($c === '"') { + rktd_skip_string($s, $i); + return; + } + + if ($c === "'") { + $i++; + rktd_skip_expr($s, $i); + return; + } + + if ($c === '#') { + if (substr($s, $i, 2) === '#(') { + $i++; + rktd_skip_expr($s, $i); + return; + } + + if (substr($s, $i, 5) === '#hash' || + substr($s, $i, 7) === '#hasheq' || + substr($s, $i, 8) === '#hasheqv') { + while ($i < $n && $s[$i] !== '(') { + $i++; + } + rktd_skip_expr($s, $i); + return; + } + } + + if ($c === '(') { + $depth = 0; + + while ($i < $n) { + $c = $s[$i]; + + if ($c === '"') { + rktd_skip_string($s, $i); + continue; + } + + if ($c === ';') { + while ($i < $n && $s[$i] !== "\n") { + $i++; + } + continue; + } + + if ($c === '(') { + $depth++; + $i++; + continue; + } + + if ($c === ')') { + $depth--; + $i++; + + if ($depth <= 0) { + return; + } + + continue; + } + + $i++; + } + + return; + } + + rktd_skip_atom($s, $i); +} + +function rktd_extract_top_level_package_names($text) +{ + $names = array(); + + $i = strpos($text, '#hash'); + + if ($i === false) { + $i = strpos($text, '#hasheq'); + } + + if ($i === false) { + $i = strpos($text, '#hasheqv'); + } + + if ($i === false) { + return array(); + } + + $n = strlen($text); + + while ($i < $n && $text[$i] !== '(') { + $i++; + } + + if ($i >= $n || $text[$i] !== '(') { + return array(); + } + + $i++; + + while ($i < $n) { + rktd_skip_space($text, $i); + + if ($i >= $n) { + break; + } + + if ($text[$i] === ')') { + break; + } + + if ($text[$i] !== '(') { + $i++; + continue; + } + + $i++; + rktd_skip_space($text, $i); + $name = rktd_read_string($text, $i); + + if ($name === null) { + while ($i < $n && $text[$i] !== ')') { + rktd_skip_expr($text, $i); + rktd_skip_space($text, $i); + } + + if ($i < $n && $text[$i] === ')') { + $i++; + } + + continue; + } + + if (rktd_package_name_ok($name)) { + $names[$name] = true; + } + + rktd_skip_space($text, $i); + + if ($i < $n && $text[$i] === '.') { + $i++; + } + + rktd_skip_expr($text, $i); + rktd_skip_space($text, $i); + + if ($i < $n && $text[$i] === ')') { + $i++; + } + } + + $out = array_keys($names); + sort($out, SORT_NATURAL | SORT_FLAG_CASE); + + return $out; +} + +function rktd_extract_catalog_source($catalogText) +{ + $patterns = array( + '/\\(\\s*source\\s*\\.\\s*"((?:[^"\\\\]|\\\\.)*)"\\s*\\)/s', + '/\\(\\s*"source"\\s*\\.\\s*"((?:[^"\\\\]|\\\\.)*)"\\s*\\)/s', + ); + + foreach ($patterns as $pat) { + if (preg_match($pat, $catalogText, $m)) { + return rktd_unescape_string($m[1]); + } + } + + return null; +} diff --git a/login.php b/login.php new file mode 100644 index 0000000..9ae3950 --- /dev/null +++ b/login.php @@ -0,0 +1,143 @@ + $lang, 'quality' => $quality); + } + } + + usort($preferences, function ($a, $b) { + return $a['quality'] < $b['quality'] ? 1 : -1; + }); + + foreach ($preferences as $preference) { + $lang = $preference['lang']; + $primary = explode('-', $lang, 2)[0]; + + if (isset($supported[$lang])) { + return $lang; + } + + if (isset($supported[$primary])) { + return $primary; + } + } + + return $fallback; +} + +$pageTitle = 'Racket ChatGPT Agent Sandbox Creator'; +$texts = array( + 'en' => array( + 'email' => 'Email address', + 'password' => 'Password', + 'login' => 'Login', + 'account_title' => 'Want to try it?', + 'account_text' => 'If you would like an account to try the sandbox, please request one from Hans Dijkema through the Racket Discourse pages.', + 'account_link' => 'Go to Racket Discourse', + ), + 'nl' => array( + 'email' => 'E-mailadres', + 'password' => 'Wachtwoord', + 'login' => 'Inloggen', + 'account_title' => 'Wil je het eens proberen?', + 'account_text' => 'Als je een account wilt om de sandbox eens uit te proberen, doe dan een verzoek aan Hans Dijkema via de Racket Discourse-pagina\'s.', + 'account_link' => 'Naar Racket Discourse', + ), +); +$language = detect_login_language($texts, 'en'); +$styleVersion = @filemtime(__DIR__ . '/styles.css') ?: time(); + +if ($auth->currentUser() !== null && $_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /'); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $auth->login($_POST['email'] ?? '', $_POST['password'] ?? ''); + header('Location: /'); + exit; + } catch (Throwable $e) { + $error = $e->getMessage(); + } +} + +header('Content-Type: text/html; charset=utf-8'); +?> + + + + +<?= h($pageTitle) ?> + + + + +
+ + + +
+ + + diff --git a/make-user.php b/make-user.php new file mode 100644 index 0000000..b7a718b --- /dev/null +++ b/make-user.php @@ -0,0 +1,52 @@ + "" + * + * Voorbeeld: + * + * php make-user.php hans@example.nl "Hans Dijkema" "lang-genoeg-wachtwoord" 1 + */ + +require_once __DIR__ . '/auth.php'; + +if (PHP_SAPI !== 'cli') { + echo "CLI only\n"; + exit(1); +} + +if ($argc < 5) { + echo "Usage:\n"; + echo " php make-user.php \"\" \n\n"; + echo "Example:\n"; + echo " php make-user.php hans@example.nl \"Hans Dijkema\" \"lang-genoeg-wachtwoord\" 1\n"; + exit(1); +} + +$email = $argv[1]; +$fullName = $argv[2]; +$password = $argv[3]; +$isAdmin = $argv[4] === '1'; + +try { + $auth = new RacketSandboxAuth(__DIR__ . '/data/racket-sandbox.sqlite'); + + $user = $auth->createUser($email, $fullName, $password, $isAdmin, true); + + echo "Created user\n"; + echo "------------\n"; + echo "ID: " . $user->id() . "\n"; + echo "Email: " . $user->email() . "\n"; + echo "Full name: " . $user->fullName() . "\n"; + echo "Admin: " . ($user->isAdmin() ? "yes" : "no") . "\n"; + echo "Enabled: " . ($user->isEnabled() ? "yes" : "no") . "\n"; + exit(0); +} catch (Throwable $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} \ No newline at end of file diff --git a/nexttoken.php b/nexttoken.php new file mode 100644 index 0000000..2faf2ce --- /dev/null +++ b/nexttoken.php @@ -0,0 +1,355 @@ +ttlSeconds = (int)$ttlSeconds; + + $dir = dirname($dbFile); + if (!is_dir($dir)) { + if (!mkdir($dir, 0700, true)) { + throw new NextTokenException('Could not create token db dir: ' . $dir); + } + } + + $this->db = new PDO('sqlite:' . $dbFile); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->db->exec('PRAGMA journal_mode = WAL'); + $this->db->exec('PRAGMA busy_timeout = 5000'); + + $this->init(); + } + + private function init() + { + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS next_tokens ( + token TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + )' + ); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_next_tokens_expires + ON next_tokens(expires_at)' + ); + + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS next_token_words ( + word TEXT NOT NULL, + kind TEXT NOT NULL, + language TEXT NOT NULL, + PRIMARY KEY(word, kind, language) + )' + ); + + $this->migrateTokenWordsTable(); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_next_token_words_kind + ON next_token_words(kind, language)' + ); + + $this->seedWords(); + } + + private function migrateTokenWordsTable() + { + $stmt = $this->db->query('PRAGMA table_info(next_token_words)'); + $pkCols = array(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + if ((int)$row['pk'] > 0) { + $pkCols[(int)$row['pk']] = $row['name']; + } + } + + ksort($pkCols); + + if (array_values($pkCols) === array('word', 'kind', 'language')) { + return; + } + + $this->db->beginTransaction(); + + try { + $this->db->exec( + 'CREATE TABLE next_token_words_new ( + word TEXT NOT NULL, + kind TEXT NOT NULL, + language TEXT NOT NULL, + PRIMARY KEY(word, kind, language) + )' + ); + + $this->db->exec( + 'INSERT OR IGNORE INTO next_token_words_new (word, kind, language) + SELECT word, kind, language + FROM next_token_words' + ); + + $this->db->exec('DROP TABLE next_token_words'); + $this->db->exec('ALTER TABLE next_token_words_new RENAME TO next_token_words'); + $this->db->commit(); + } catch (Throwable $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + + throw $e; + } + } + + public function create($ttlSeconds = null) + { + $ttl = $ttlSeconds === null ? $this->ttlSeconds : (int)$ttlSeconds; + + if ($ttl <= 0) { + throw new NextTokenException('Invalid ttl.'); + } + + $this->cleanup(); + + $now = time(); + + $stmt = $this->db->prepare( + 'INSERT INTO next_tokens (token, created_at, expires_at) + VALUES (:token, :created_at, :expires_at)' + ); + + for ($i = 0; $i < 25; $i++) { + $token = $this->newToken(); + + try { + $stmt->execute(array( + ':token' => $token, + ':created_at' => $now, + ':expires_at' => $now + $ttl, + )); + + return $token; + } catch (PDOException $e) { + if ($e->getCode() !== '23000') { + throw $e; + } + } + } + + throw new NextTokenException('Could not create unique next token.'); + } + + public function isValid($token) + { + if (!is_string($token) || !preg_match('/^[a-z0-9-]{5,160}$/', $token)) { + return false; + } + + $stmt = $this->db->prepare( + 'SELECT expires_at + FROM next_tokens + WHERE token = :token' + ); + + $stmt->execute(array(':token' => $token)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return false; + } + + return time() <= (int)$row['expires_at']; + } + + public function requireValid($token) + { + if (!$this->isValid($token)) { + throw new NextTokenException('Invalid or expired next token.'); + } + + return true; + } + + public function cleanup() + { + $stmt = $this->db->prepare( + 'DELETE FROM next_tokens + WHERE expires_at < :now' + ); + + $stmt->execute(array(':now' => time())); + return $stmt->rowCount(); + } + + public function currentTokens() + { + $stmt = $this->db->prepare( + 'SELECT token, created_at, expires_at + FROM next_tokens + WHERE expires_at >= :now + ORDER BY expires_at ASC, created_at ASC' + ); + + $stmt->execute(array(':now' => time())); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function tokens() + { + $stmt = $this->db->query( + 'SELECT token, created_at, expires_at + FROM next_tokens + ORDER BY expires_at ASC, created_at ASC' + ); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function expired_html_response() + { + html_response(' + + + + Expired link + + +

Expired or unauthorized link

+

+ This link is no longer valid. Please ask the human user for a new + bootstrap link and restart the process from that link. +

+

+ For the AI agent: do not try to invent a new next value. + Only follow links that appear on authorized HTML pages. +

+ + ', 200); + } + + public function expired_text_response() + { + text_response( + "Expired or unauthorized link.\n" . + "Please ask the human user for a new bootstrap link.\n" . + "Do not invent a new next value; only follow links from authorized HTML pages.\n", + 200 + ); + } + + public function expired_response($kind = 'html') + { + if ($kind === 'html') { + $this->expired_html_response(); + } else { + $this->expired_text_response(); + } + } + + public function check_valid_next($kind = 'html') + { + try { + $this->requireValid($_GET['next'] ?? ''); + } catch (Throwable $e) { + $this->expired_response($kind); + } + } + + private function newToken() + { + $adjective = $this->randomWord('adjective'); + $noun = $this->randomWord('noun'); + + return $adjective . '-' . $noun; + } + + private function randomWord($kind) + { + $stmt = $this->db->prepare( + 'SELECT word + FROM next_token_words + WHERE kind = :kind + ORDER BY RANDOM() + LIMIT 1' + ); + $stmt->execute(array(':kind' => $kind)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new NextTokenException('No token words available for: ' . $kind); + } + + return (string)$row['word']; + } + + private function seedWords() + { + $wordsFile = __DIR__ . '/config/next-token-words.php'; + + if (!is_file($wordsFile)) { + throw new NextTokenException('Next token word seed file not found.'); + } + + $words = require $wordsFile; + + if (!is_array($words)) { + throw new NextTokenException('Invalid next token word seed file.'); + } + + $stmt = $this->db->prepare( + 'INSERT OR IGNORE INTO next_token_words + (word, kind, language) + VALUES (:word, :kind, :language)' + ); + + foreach ($words as $language => $byKind) { + if (!is_array($byKind)) { + continue; + } + + foreach ($byKind as $kind => $list) { + if ($kind !== 'adjective' && $kind !== 'noun') { + continue; + } + + if (!is_array($list)) { + continue; + } + + foreach ($list as $word) { + $word = trim((string)$word); + + if (!preg_match('/^[a-z0-9-]+$/', $word)) { + continue; + } + + $stmt->execute(array( + ':word' => $word, + ':kind' => $kind, + ':language' => $language, + )); + } + } + } + } +} diff --git a/package.php b/package.php new file mode 100644 index 0000000..febc320 --- /dev/null +++ b/package.php @@ -0,0 +1,476 @@ +&next=... + * HTML-pagina met base64 part-links voor data/.zip. + * + * /package-part?name=&part=000001&next=... + * text/plain met base64-inhoud van één part. + * + * Regels: + * + * - HTML voor index/pagina's. + * - text/plain voor payload. + * - Payload is altijd base64. + * - Eén next-id per gegenereerde HTML-pagina. + */ + +ini_set('display_errors', '1'); +ini_set('display_startup_errors', '1'); +ini_set('log_errors', '1'); +error_reporting(E_ALL); + +require_once __DIR__ . '/nexttoken.php'; + +$TOKENS = new NextTokenStore(__DIR__ . '/data/racket-sandbox.sqlite'); + +@set_time_limit(300); +ignore_user_abort(false); + +require_once __DIR__ . '/gitfetcher.php'; +require_once __DIR__ . '/b64parts.php'; +require_once __DIR__ . '/base64config.php'; +require_once __DIR__ . '/lib/catalog-http.php'; +require_once __DIR__ . '/lib/racket-data.php'; + +define('DATA_DIR', __DIR__ . '/data'); +define('CATALOG_PACKAGE_BASE', 'https://pkgs.racket-lang.org/pkg/'); +define('CATALOG_CACHE_TTL', 3600); + +$chunkConfig = load_base64_chunk_config(); +$packageZipMaxBase64Kb = (int)($chunkConfig['package_zip_max_base64_kb'] ?? 2048); + +if ($packageZipMaxBase64Kb < 1) { + $packageZipMaxBase64Kb = 1; +} + +define('PACKAGE_ZIP_MAX_BASE64_KB', $packageZipMaxBase64Kb); +define('PACKAGE_ZIP_MAX_BASE64_BYTES', PACKAGE_ZIP_MAX_BASE64_KB * 1024); + +$NEXT_ID = $TOKENS->create(); + +function path_only() +{ + $p = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); + $p = '/' . trim($p ?: '/', '/'); + return $p === '/' ? '/' : $p; +} + +function h($s) +{ + return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function current_scheme() +{ + if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0])); + } + + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +} + +function current_host() +{ + return $_SERVER['HTTP_HOST'] ?? 'localhost'; +} + +function make_url($path, $query = array()) +{ + global $NEXT_ID; + + $query['next'] = $NEXT_ID; + + return current_scheme() . '://' . current_host() . $path . '?' . http_build_query($query); +} + +function html_response($html, $status = 200) +{ + http_response_code($status); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + echo $html; + exit; +} + +function text_response($text, $status = 200) +{ + http_response_code($status); + header('Content-Type: text/plain; charset=us-ascii'); + header('Content-Disposition: inline'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + + echo $text; + + if ($text === '' || substr($text, -1) !== "\n") { + echo "\n"; + } + + exit; +} + +function fail_html($message, $status = 500) +{ + html_response( + '' . + 'Package error' . + '

Package error

' . + '
' . h($message) . '
' . + '', + $status + ); +} + +function fail_text($message, $status = 500) +{ + text_response("error: " . $message . "\n", $status); +} + +function package_name_ok($name) +{ + return rktd_package_name_ok($name); +} + +function ensure_data_dir() +{ + try { + catalog_http_ensure_dir(DATA_DIR); + } catch (Throwable $e) { + fail_html($e->getMessage()); + } +} + +function catalog_cache_file($package) +{ + ensure_data_dir(); + return DATA_DIR . '/catalog-' . sha1($package) . '.rktd'; +} + +function catalog_meta_file($package) +{ + ensure_data_dir(); + return DATA_DIR . '/catalog-' . sha1($package) . '.meta.json'; +} + +function get_catalog_package_text($package) +{ + $cacheFile = catalog_cache_file($package); + $url = CATALOG_PACKAGE_BASE . rawurlencode($package); + + try { + return catalog_http_fetch_cached( + $url, + $cacheFile, + catalog_meta_file($package), + CATALOG_CACHE_TTL, + 'rktsndbx-package-entry/1.0', + 180 + ); + } catch (Throwable $e) { + fail_html($e->getMessage()); + } +} + +function extract_catalog_source($catalogText) +{ + return rktd_extract_catalog_source($catalogText); +} + +function normalize_source_to_repo_url($source) +{ + $source = trim((string)$source); + + if ($source === '') { + fail_html('Lege package source.'); + } + + /* + * github://github.com/owner/repo[/branch[/subdir]] + * Voor nu gebruiken we owner/repo. Subdirs lossen we later op. + */ + $p = parse_url($source); + + if ($p === false || empty($p['scheme'])) { + fail_html('Kan package source niet parsen: ' . $source); + } + + $scheme = strtolower($p['scheme']); + + if ($scheme === 'github') { + $host = !empty($p['host']) ? strtolower($p['host']) : 'github.com'; + $path = trim($p['path'] ?? '', '/'); + $bits = explode('/', $path); + + if ($host !== 'github.com' || count($bits) < 2) { + fail_html('Ongeldige github package source: ' . $source); + } + + return 'https://github.com/' . $bits[0] . '/' . $bits[1]; + } + + /* + * git+https://... normaliseren. gitfetcher.php kan hier ook deels mee + * omgaan, maar dit houdt metadata netter. + */ + if (strpos($source, 'git+https://') === 0) { + return 'https://' . substr($source, strlen('git+https://')); + } + + if (strpos($source, 'git+http://') === 0) { + return 'http://' . substr($source, strlen('git+http://')); + } + + /* + * Verwijder query/fragment voor de repository-fetch. + * ?path=... en #branch/subdir pakken we later apart aan. + */ + $source = preg_replace('/[?#].*$/', '', $source); + + return $source; +} + +function ensure_package_zip_and_parts($package) +{ + $catalogText = get_catalog_package_text($package); + $source = extract_catalog_source($catalogText); + + if ($source === null || $source === '') { + fail_html('Geen source gevonden in Racket package catalogus voor package: ' . $package); + } + + $repoUrl = normalize_source_to_repo_url($source); + + $fetcher = new GitFetcher(array( + 'data_dir' => DATA_DIR, + )); + + try { + $zipInfo = $fetcher->ensurePackageZip($package, $repoUrl); + } catch (Throwable $e) { + fail_html( + "Kon package zip niet ophalen.\n\n" . + "Package: " . $package . "\n" . + "Catalog source: " . $source . "\n" . + "Repo URL: " . $repoUrl . "\n\n" . + $e->getMessage() + ); + } + + $zipFile = DATA_DIR . '/' . $package . '.zip'; + + if (!is_file($zipFile) || !is_readable($zipFile)) { + fail_html('Zipbestand ontbreekt na fetch: ' . $zipFile); + } + + $parts = new Base64Parts(DATA_DIR, PACKAGE_ZIP_MAX_BASE64_BYTES); + + try { + $manifest = ensure_parts_for_zip($parts, $package, $zipFile); + } catch (Throwable $e) { + fail_html('Kon base64-parts niet maken: ' . $e->getMessage()); + } + + return array( + 'package' => $package, + 'source' => $source, + 'repo_url' => $repoUrl, + 'zip_info' => $zipInfo, + 'manifest' => $manifest, + ); +} + +function ensure_parts_for_zip($parts, $package, $zipFile) +{ + $zipSha = hash_file('sha256', $zipFile); + $zipSize = filesize($zipFile); + + /* + * Hergebruik bestaande parts als ze nog exact bij de zip horen. + */ + try { + $manifest = $parts->loadManifest($package); + + if (isset($manifest['source_sha256']) && + $manifest['source_sha256'] === $zipSha && + isset($manifest['source_bytes']) && + (int)$manifest['source_bytes'] === (int)$zipSize && + isset($manifest['max_base64_bytes']) && + (int)$manifest['max_base64_bytes'] === PACKAGE_ZIP_MAX_BASE64_BYTES && + isset($manifest['parts']) && + is_array($manifest['parts']) && + count($manifest['parts']) > 0) { + + $ok = true; + + foreach ($manifest['parts'] as $part) { + if (empty($part['file']) || !is_file($part['file']) || !is_readable($part['file'])) { + $ok = false; + break; + } + } + + if ($ok) { + $manifest['parts_status'] = 'cached'; + return $manifest; + } + } + } catch (Throwable $e) { + /* + * Geen manifest of ongeldig manifest: gewoon opnieuw maken. + */ + } + + $manifest = $parts->splitFile($zipFile, $package, true); + $manifest['parts_status'] = 'created'; + + return $manifest; +} + +function serve_package_page() +{ + global $NEXT_ID; + + $package = $_GET['name'] ?? ''; + + if (!package_name_ok($package)) { + fail_html('Ongeldige of ontbrekende package naam.', 400); + } + + $info = ensure_package_zip_and_parts($package); + + $manifest = $info['manifest']; + $zipInfo = $info['zip_info']; + + $rows = ''; + + foreach ($manifest['parts'] as $part) { + $n = $part['number']; + + $url = make_url('/package-part', array( + 'name' => $package, + 'part' => $n, + )); + + $rows .= + '' . + '' . h($n) . '' . + '' . h((string)$part['base64_bytes']) . '' . + '' . h($url) . '' . + '' . "\n"; + } + + html_response(' + + + +Package ' . h($package) . ' + + + + +

Package ' . h($package) . '

+ +

+Deze pagina is HTML. Alle part-links hieronder geven text/plain +met base64-inhoud terug. Dezelfde next wordt gebruikt voor alle +part-links op deze pagina. +

+ +

Bron

+ + + + + + + + + + + + + + + +
catalog source' . h($info['source']) . '
repo url' . h($info['repo_url']) . '
fetch status' . h($zipInfo['status'] ?? '') . '
default branch' . h($zipInfo['default_branch'] ?? '') . '
head sha' . h($zipInfo['head_sha'] ?? '') . '
zip file' . h($zipInfo['zip_file'] ?? '') . '
zip bytes' . h((string)($zipInfo['zip_bytes'] ?? '')) . '
zip sha256' . h($zipInfo['zip_sha256'] ?? '') . '
parts status' . h($manifest['parts_status'] ?? '') . '
max base64 part size' . h((string)PACKAGE_ZIP_MAX_BASE64_KB) . ' KiB (' . h((string)PACKAGE_ZIP_MAX_BASE64_BYTES) . ' bytes)
binary chunk size' . h((string)($manifest['binary_chunk_bytes'] ?? '')) . ' bytes
part count' . h((string)$manifest['part_count']) . '
next id' . h($NEXT_ID) . '
+ +

Base64 parts

+ + + + + + + + + + +' . $rows . ' + +
partbase64 bytestext/plain URL
+ +

Reconstructie in de sandbox

+ +
+# download alle links als:
+# ' . h($package) . '.part.000001.b64
+# ' . h($package) . '.part.000002.b64
+# enz.
+
+cat ' . h($package) . '.part.*.b64 > ' . h($package) . '.zip.b64
+base64 -d ' . h($package) . '.zip.b64 > ' . h($package) . '.zip
+raco pkg install --auto ./' . h($package) . '.zip
+
+ + +'); +} + +function serve_package_part() +{ + $package = $_GET['name'] ?? ''; + $part = $_GET['part'] ?? ''; + + if (!package_name_ok($package)) { + fail_text('Ongeldige of ontbrekende package naam.', 400); + } + + if (!is_string($part) || !preg_match('/^[0-9]{6}$/', $part)) { + fail_text('Ongeldig of ontbrekend partnummer.', 400); + } + + $parts = new Base64Parts(DATA_DIR, PACKAGE_ZIP_MAX_BASE64_BYTES); + + try { + $txt = $parts->readPart($package, $part); + } catch (Throwable $e) { + fail_text($e->getMessage(), 404); + } + + text_response($txt, 200); +} + +$path = path_only(); + +if ($path === '/package') { + $TOKENS->check_valid_next('html'); + serve_package_page(); +} + +if ($path === '/package-part') { + $TOKENS->check_valid_next('text'); + serve_package_part(); +} + +fail_html('Onbekende route: ' . $path, 404); diff --git a/prompt-editor.js b/prompt-editor.js new file mode 100644 index 0000000..f7f3bea --- /dev/null +++ b/prompt-editor.js @@ -0,0 +1,516 @@ +(function () { + 'use strict'; + + let promptData = { personal: {}, default: {}, can_edit_defaults: false }; + let currentKind = null; + let currentPrompt = null; + let currentVersions = []; + let versionIndex = 0; + let uiText = { + prompt_not_found: 'Prompt not found', + no_previous_versions: 'No previous versions stored.', + no_lines_for_view: 'No lines for this view.', + restore_version_confirm: 'Restore version', + delete_version_confirm: 'Delete version', + default_prompt_prefix: 'Default prompt: ', + prompt_prefix: 'Prompt: ', + created: 'created', + updated: 'updated', + default_prompt: 'default prompt', + version: 'version', + showing_version: 'showing version', + of: 'of', + old: 'old', + new: 'new' + }; + + function byId(id) { + return document.getElementById(id); + } + + function readPromptData() { + const el = byId('promptDataJson'); + const textEl = byId('promptTextJson'); + + if (!el) { + return; + } + + try { + promptData = JSON.parse(el.textContent || '{}'); + } catch (e) { + console.error('Could not parse prompt data JSON', e); + promptData = { personal: {}, default: {} }; + } + + if (!textEl) { + return; + } + + try { + uiText = Object.assign(uiText, JSON.parse(textEl.textContent || '{}')); + } catch (e) { + console.error('Could not parse prompt text JSON', e); + } + } + + function esc(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function fmtTs(ts) { + if (!ts) { + return '-'; + } + + const d = new Date(ts * 1000); + return d.toISOString().replace('T', ' ').slice(0, 19); + } + + function promptActionUrl(kind) { + const url = new URL(window.location.href); + url.pathname = '/prompts'; + url.searchParams.set('mode', kind === 'default' ? 'defaults' : 'personal'); + return url.pathname + url.search; + } + + function setEditing(editing) { + const shell = byId('promptModalForm'); + const mayEdit = currentKind !== 'default' || promptData.can_edit_defaults; + editing = editing && mayEdit; + + shell.classList.toggle('edit-mode', editing); + shell.classList.toggle('can-edit', mayEdit); + shell.classList.toggle('read-only-default', currentKind === 'default' && !mayEdit); + + byId('modalName').readOnly = !editing; + byId('modalContent').readOnly = !editing; + byId('modalDefaultKey').readOnly = !editing; + byId('modalLanguage').disabled = !editing; + } + + function openEditModal() { + if (!currentPrompt) { + return; + } + + if (currentKind === 'default' && !promptData.can_edit_defaults) { + return; + } + + setEditing(true); + byId('promptModalBackdrop').classList.add('open'); + renderVersionPane(); + } + + function closeEditModal() { + byId('promptModalBackdrop').classList.remove('open'); + if (currentPrompt) { + resetEditorFields(); + renderVersionPane(); + } + } + + function resetEditorFields() { + byId('modalName').value = currentPrompt.name; + byId('modalLanguage').value = currentPrompt.language; + byId('modalContent').value = currentPrompt.content; + byId('modalDefaultKey').value = currentPrompt.default_key || ''; + setEditing(false); + } + + function openPromptEditor(kind, id) { + currentKind = kind; + currentPrompt = promptData[kind] ? promptData[kind][id] : null; + + if (!currentPrompt) { + alert(uiText.prompt_not_found); + return; + } + + currentVersions = currentPrompt.versions || []; + currentVersions.sort(function (a, b) { + return b.version_no - a.version_no; + }); + + /* + * The newest stored version is often the current snapshot. If possible, + * start by showing the one before that. + */ + versionIndex = currentVersions.length > 1 ? 1 : 0; + + byId('modalTitle').textContent = + (kind === 'default' ? uiText.default_prompt_prefix : uiText.prompt_prefix) + + currentPrompt.name; + byId('viewerTitle').textContent = + (kind === 'default' ? uiText.default_prompt_prefix : uiText.prompt_prefix) + + currentPrompt.name; + + byId('modalMeta').textContent = + uiText.created + ' ' + fmtTs(currentPrompt.created_at) + + ' · ' + uiText.updated + ' ' + fmtTs(currentPrompt.updated_at) + + (kind === 'default' && !promptData.can_edit_defaults ? ' · ' + uiText.default_prompt : ''); + byId('viewerMeta').textContent = byId('modalMeta').textContent; + byId('viewerContent').textContent = currentPrompt.content; + + byId('defaultKeyRow').style.display = kind === 'default' ? 'block' : 'none'; + + if (kind === 'default') { + byId('modalAction').value = 'update_default'; + byId('modalDefaultId').value = currentPrompt.id; + byId('modalPromptId').value = ''; + } else { + byId('modalAction').value = 'update_prompt'; + byId('modalPromptId').value = currentPrompt.id; + byId('modalDefaultId').value = ''; + } + + byId('promptModalForm').action = promptActionUrl(kind); + byId('modalAuxForm').action = promptActionUrl(kind); + + byId('promptViewer').classList.remove('is-empty'); + byId('promptViewer').classList.toggle('is-default-prompt', kind === 'default'); + byId('promptModalBackdrop').classList.toggle('is-default-prompt', kind === 'default'); + byId('editPromptButton').style.display = + kind === 'default' && !promptData.can_edit_defaults ? 'none' : ''; + document.querySelectorAll('.prompt-select').forEach(function (button) { + button.classList.toggle( + 'selected', + button.dataset.kind === kind && button.dataset.id === String(id) + ); + }); + + resetEditorFields(); + renderVersionPane(); + } + + function currentSelectedVersion() { + if (currentVersions.length === 0) { + return null; + } + + if (versionIndex < 0) { + versionIndex = 0; + } + + if (versionIndex >= currentVersions.length) { + versionIndex = currentVersions.length - 1; + } + + return currentVersions[versionIndex]; + } + + function olderVersion() { + if (currentVersions.length === 0) { + return; + } + + if (versionIndex < currentVersions.length - 1) { + versionIndex++; + renderVersionPane(); + } + } + + function newerVersion() { + if (currentVersions.length === 0) { + return; + } + + const minIndex = currentVersions.length > 1 ? 1 : 0; + + if (versionIndex > minIndex) { + versionIndex--; + renderVersionPane(); + } + } + + function renderVersionPane() { + const version = currentSelectedVersion(); + const contentEl = byId('versionContent'); + const metaEl = byId('selectedVersionMeta'); + const indicatorEl = byId('versionIndicator'); + + if (!version) { + contentEl.innerHTML = '' + + esc(uiText.no_previous_versions) + + ''; + metaEl.textContent = ''; + indicatorEl.textContent = ''; + return; + } + + metaEl.textContent = + uiText.version + ' ' + version.version_no + + ' · ' + fmtTs(version.created_at) + + (version.note ? ' · ' + version.note : ''); + + indicatorEl.textContent = + uiText.showing_version + ' ' + version.version_no + + ' (' + (versionIndex + 1) + ' ' + uiText.of + ' ' + currentVersions.length + ')'; + + const currentText = byId('modalContent').value; + const oldText = version.content; + const mode = byId('diffMode').value; + + if (mode === 'plain') { + contentEl.innerHTML = esc(oldText); + return; + } + + contentEl.innerHTML = renderLineDiff(oldText, currentText, mode); + } + + function linesOf(s) { + return String(s).split(/\r?\n/); + } + + function lcsTable(a, b) { + const m = a.length; + const n = b.length; + const dp = Array.from({ length: m + 1 }, function () { + return Array(n + 1).fill(0); + }); + + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + if (a[i] === b[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + return dp; + } + + function diffOps(oldLines, newLines) { + const dp = lcsTable(oldLines, newLines); + const ops = []; + let i = 0; + let j = 0; + + while (i < oldLines.length && j < newLines.length) { + if (oldLines[i] === newLines[j]) { + ops.push({ type: 'same', oldLine: oldLines[i], newLine: newLines[j] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + ops.push({ type: 'deleted', oldLine: oldLines[i] }); + i++; + } else { + ops.push({ type: 'added', newLine: newLines[j] }); + j++; + } + } + + while (i < oldLines.length) { + ops.push({ type: 'deleted', oldLine: oldLines[i] }); + i++; + } + + while (j < newLines.length) { + ops.push({ type: 'added', newLine: newLines[j] }); + j++; + } + + return pairChangedLines(ops); + } + + function pairChangedLines(ops) { + const paired = []; + + for (let k = 0; k < ops.length; k++) { + const a = ops[k]; + const b = ops[k + 1]; + + if (a && b && a.type === 'deleted' && b.type === 'added') { + paired.push({ + type: 'changed', + oldLine: a.oldLine, + newLine: b.newLine + }); + k++; + } else if (a && b && a.type === 'added' && b.type === 'deleted') { + paired.push({ + type: 'changed', + oldLine: b.oldLine, + newLine: a.newLine + }); + k++; + } else { + paired.push(a); + } + } + + return paired; + } + + function renderLineDiff(oldText, newText, mode) { + const ops = diffOps(linesOf(oldText), linesOf(newText)); + const out = []; + + for (const op of ops) { + if (mode !== 'all' && mode !== op.type) { + continue; + } + + if (op.type === 'same') { + out.push('' + esc(op.oldLine) + ''); + } else if (op.type === 'added') { + out.push('+ ' + esc(op.newLine) + ''); + } else if (op.type === 'deleted') { + out.push('- ' + esc(op.oldLine) + ''); + } else if (op.type === 'changed') { + out.push( + '~ ' + + esc(uiText.old) + + ': ' + + esc(op.oldLine) + + '' + ); + out.push( + '~ ' + + esc(uiText.new) + + ': ' + + esc(op.newLine) + + '' + ); + } + } + + if (out.length === 0) { + return '' + esc(uiText.no_lines_for_view) + ''; + } + + return out.join(''); + } + + function configureAux(action, versionNo) { + const aux = byId('modalAuxForm'); + + byId('auxAction').value = action; + byId('auxVersionNo').value = versionNo || ''; + byId('auxVersionNote').value = 'modal snapshot'; + + if (currentKind === 'default') { + byId('auxDefaultId').value = currentPrompt.id; + byId('auxPromptId').value = ''; + } else { + byId('auxPromptId').value = currentPrompt.id; + byId('auxDefaultId').value = ''; + } + + return aux; + } + + function storeSnapshot() { + if (!currentPrompt) { + return; + } + + const action = currentKind === 'default' + ? 'create_default_version' + : 'create_version'; + + configureAux(action, '').submit(); + } + + function restoreSelectedVersion() { + const version = currentSelectedVersion(); + + if (!currentPrompt || !version) { + return; + } + + if (!confirm(uiText.restore_version_confirm + ' ' + version.version_no + '?')) { + return; + } + + const action = currentKind === 'default' + ? 'restore_default_version' + : 'restore_version'; + + configureAux(action, version.version_no).submit(); + } + + function deleteSelectedVersion() { + const version = currentSelectedVersion(); + + if (!currentPrompt || !version) { + return; + } + + if (!confirm(uiText.delete_version_confirm + ' ' + version.version_no + '?')) { + return; + } + + const action = currentKind === 'default' + ? 'delete_default_version' + : 'delete_version'; + + configureAux(action, version.version_no).submit(); + } + + function bindEvents() { + document.querySelectorAll('.prompt-tab').forEach(function (tab) { + tab.addEventListener('click', function () { + const tabName = tab.dataset.tab; + + document.querySelectorAll('.prompt-tab').forEach(function (other) { + const active = other === tab; + other.classList.toggle('active', active); + other.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + + document.querySelectorAll('.prompt-tab-panel').forEach(function (panel) { + panel.classList.toggle('active', panel.id === 'tab-' + tabName); + }); + }); + }); + + document.querySelectorAll('.js-open-prompt').forEach(function (button) { + button.addEventListener('click', function () { + openPromptEditor(button.dataset.kind, button.dataset.id); + }); + }); + + byId('editPromptButton').addEventListener('click', function () { + openEditModal(); + }); + byId('cancelEditButton').addEventListener('click', function () { + closeEditModal(); + }); + byId('versionNewerButton').addEventListener('click', newerVersion); + byId('versionOlderButton').addEventListener('click', olderVersion); + byId('diffMode').addEventListener('change', renderVersionPane); + byId('modalContent').addEventListener('input', renderVersionPane); + byId('snapshotButton').addEventListener('click', storeSnapshot); + byId('restoreVersionButton').addEventListener('click', restoreSelectedVersion); + byId('deleteVersionButton').addEventListener('click', deleteSelectedVersion); + + byId('promptModalBackdrop').addEventListener('click', function (ev) { + if (ev.target === byId('promptModalBackdrop')) { + closeEditModal(); + } + }); + + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape') { + closeEditModal(); + } + }); + + setEditing(false); + } + + document.addEventListener('DOMContentLoaded', function () { + readPromptData(); + bindEvents(); + }); +}()); diff --git a/prompts.php b/prompts.php new file mode 100644 index 0000000..2603b35 --- /dev/null +++ b/prompts.php @@ -0,0 +1,720 @@ +requireLoginHtml(); + +$store = new PromptStore($DB_FILE); +$languageStore = new LanguageStore($DB_FILE); +$userSettings = new UserSettingsStore($DB_FILE); + +$message = ''; +$error = ''; + +function h($s) +{ + return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function t($key, $fallback = null) +{ + global $languageStore, $language; + + return $languageStore->translate($key, $language, $fallback); +} + +function post_value($name, $default = '') +{ + return $_POST[$name] ?? $default; +} + +function get_value($name, $default = '') +{ + return $_GET[$name] ?? $default; +} + +function fmt_time($ts) +{ + if ($ts === null) { + return '-'; + } + + return date('Y-m-d H:i:s', (int)$ts); +} + +function prompt_to_array($prompt) +{ + return array( + 'id' => $prompt->id(), + 'name' => $prompt->name(), + 'language' => $prompt->language(), + 'content' => $prompt->content(), + 'default_key' => $prompt->defaultKey(), + 'is_default' => $prompt->isDefault(), + 'created_at' => $prompt->createdAt(), + 'updated_at' => $prompt->updatedAt(), + ); +} + +function version_to_array($version) +{ + return array( + 'id' => $version->id(), + 'prompt_id' => $version->promptId(), + 'version_no' => $version->versionNo(), + 'name' => $version->name(), + 'language' => $version->language(), + 'content' => $version->content(), + 'note' => $version->note(), + 'created_at' => $version->createdAt(), + ); +} + +function resolve_user_language($userSettings, $userId, $allowedLanguages) +{ + $language = isset($_GET['lang']) + ? (string)$_GET['lang'] + : (string)$userSettings->get($userId, 'language', 'en'); + + if (!in_array($language, $allowedLanguages, true)) { + $language = 'en'; + } + + $userSettings->set($userId, 'language', $language); + + return $language; +} + +$language = resolve_user_language( + $userSettings, + $user->id(), + $store->supportedLanguages() +); + +$languageStore->seedDefaults(array( + 'prompts.title' => array('en' => 'Prompt administration', 'nl' => 'Promptbeheer'), + 'prompts.back' => array('en' => 'Back to Racket sandbox', 'nl' => 'Terug naar Racket sandbox'), + 'prompts.your_prompts' => array('en' => 'Your prompts', 'nl' => 'Jouw prompts'), + 'prompts.default_admin' => array('en' => 'Default prompt administration', 'nl' => 'Standaardpromptbeheer'), + 'prompts.language' => array('en' => 'Language', 'nl' => 'Taal'), + 'prompts.available_defaults' => array('en' => 'Available default prompts', 'nl' => 'Beschikbare standaardprompts'), + 'prompts.default_admin_badge' => array('en' => 'Admin default prompts', 'nl' => 'Admin standaardprompts'), + 'prompts.default_admin_hint' => array( + 'en' => 'You are editing global default prompts. Users can copy these to their own prompts.', + 'nl' => 'Je bewerkt globale standaardprompts. Gebruikers kunnen deze naar hun eigen prompts kopieren.' + ), + 'prompts.copy_all' => array('en' => 'Copy all', 'nl' => 'Alles kopieren'), + 'prompts.copy' => array('en' => 'copy', 'nl' => 'kopieer'), + 'prompts.delete' => array('en' => 'delete', 'nl' => 'verwijder'), + 'prompts.create_default' => array('en' => 'Create default prompt', 'nl' => 'Standaardprompt maken'), + 'prompts.create_personal' => array('en' => 'Create personal prompt', 'nl' => 'Persoonlijke prompt maken'), + 'prompts.name' => array('en' => 'Name', 'nl' => 'Naam'), + 'prompts.default_key' => array('en' => 'Default key', 'nl' => 'Standaardsleutel'), + 'prompts.prompt_content' => array('en' => 'Prompt content', 'nl' => 'Promptinhoud'), + 'prompts.no_defaults' => array('en' => 'No default prompts for this language yet.', 'nl' => 'Nog geen standaardprompts voor deze taal.'), + 'prompts.no_personal' => array('en' => 'No personal prompts yet for this language.', 'nl' => 'Nog geen persoonlijke prompts voor deze taal.'), + 'prompts.select_prompt' => array('en' => 'Select a prompt on the left to view it.', 'nl' => 'Selecteer links een prompt om deze te bekijken.'), + 'prompts.edit' => array('en' => 'Edit', 'nl' => 'Bewerk'), + 'prompts.close' => array('en' => 'Close', 'nl' => 'Sluiten'), + 'prompts.newer_previous' => array('en' => 'newer previous version', 'nl' => 'nieuwere vorige versie'), + 'prompts.older_previous' => array('en' => 'older previous version', 'nl' => 'oudere vorige versie'), + 'prompts.diff_view' => array('en' => 'Diff view:', 'nl' => 'Verschilweergave:'), + 'prompts.diff_plain' => array('en' => 'text, no diff', 'nl' => 'tekst, geen verschil'), + 'prompts.diff_all' => array('en' => 'all diff', 'nl' => 'alle verschillen'), + 'prompts.diff_same' => array('en' => 'unchanged only', 'nl' => 'alleen ongewijzigd'), + 'prompts.diff_added' => array('en' => 'additions only', 'nl' => 'alleen toevoegingen'), + 'prompts.diff_deleted' => array('en' => 'deletions only', 'nl' => 'alleen verwijderingen'), + 'prompts.diff_changed' => array('en' => 'changes only', 'nl' => 'alleen wijzigingen'), + 'prompts.previous_version' => array('en' => 'Previous version', 'nl' => 'Vorige versie'), + 'prompts.store_version' => array('en' => 'store this edit as a new version', 'nl' => 'bewaar deze bewerking als nieuwe versie'), + 'prompts.version_note' => array('en' => 'Version note:', 'nl' => 'Versienotitie:'), + 'prompts.save' => array('en' => 'Save', 'nl' => 'Opslaan'), + 'prompts.store_snapshot' => array('en' => 'Store snapshot', 'nl' => 'Snapshot bewaren'), + 'prompts.restore_version' => array('en' => 'Restore selected version', 'nl' => 'Geselecteerde versie herstellen'), + 'prompts.delete_version' => array('en' => 'Delete selected version', 'nl' => 'Geselecteerde versie verwijderen'), + 'prompts.prompt' => array('en' => 'Prompt', 'nl' => 'Prompt'), + 'prompts.prompt_not_found' => array('en' => 'Prompt not found', 'nl' => 'Prompt niet gevonden'), + 'prompts.no_previous_versions' => array('en' => 'No previous versions stored.', 'nl' => 'Geen vorige versies opgeslagen.'), + 'prompts.no_lines_for_view' => array('en' => 'No lines for this view.', 'nl' => 'Geen regels voor deze weergave.'), + 'prompts.restore_version_confirm' => array('en' => 'Restore version', 'nl' => 'Versie herstellen'), + 'prompts.delete_version_confirm' => array('en' => 'Delete version', 'nl' => 'Versie verwijderen'), + 'prompts.delete_default_confirm' => array('en' => 'Delete default prompt', 'nl' => 'Standaardprompt verwijderen'), + 'prompts.delete_prompt_confirm' => array('en' => 'Delete prompt', 'nl' => 'Prompt verwijderen'), + 'prompts.default_prompt_prefix' => array('en' => 'Default prompt: ', 'nl' => 'Standaardprompt: '), + 'prompts.prompt_prefix' => array('en' => 'Prompt: ', 'nl' => 'Prompt: '), + 'prompts.created' => array('en' => 'created', 'nl' => 'gemaakt'), + 'prompts.updated' => array('en' => 'updated', 'nl' => 'bijgewerkt'), + 'prompts.default_prompt' => array('en' => 'default prompt', 'nl' => 'standaardprompt'), + 'prompts.version' => array('en' => 'version', 'nl' => 'versie'), + 'prompts.showing_version' => array('en' => 'showing version', 'nl' => 'toont versie'), + 'prompts.of' => array('en' => 'of', 'nl' => 'van'), + 'prompts.old' => array('en' => 'old', 'nl' => 'oud'), + 'prompts.new' => array('en' => 'new', 'nl' => 'nieuw'), + 'app.admin' => array('en' => 'admin', 'nl' => 'admin'), + 'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'), + 'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'), + 'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'), +)); + +$mode = get_value('mode', 'personal'); + +if ($mode !== 'personal' && $mode !== 'defaults') { + $mode = 'personal'; +} + +if ($mode === 'defaults' && !$user->isAdmin()) { + $mode = 'personal'; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = post_value('action'); + + try { + /* + * User actions. + */ + if ($action === 'logout') { + $auth->logout(); + header('Location: /login.php'); + exit; + } elseif ($action === 'copy_all_defaults') { + $language = post_value('language', 'en'); + $created = $store->copyAllDefaultsToUser($user->id(), $language); + $message = 'Default prompts copied to your prompts: ' . $created; + $mode = 'personal'; + } elseif ($action === 'copy_default') { + $defaultId = (int)post_value('default_id'); + $store->copyDefaultPromptToUser($user->id(), $defaultId); + $message = 'Default prompt copied to your prompts.'; + $mode = 'personal'; + } elseif ($action === 'create_prompt') { + $language = post_value('language', 'en'); + + $store->createPrompt( + $user->id(), + post_value('name'), + $language, + post_value('content') + ); + + $message = 'Prompt created.'; + $mode = 'personal'; + } elseif ($action === 'update_prompt') { + $promptId = (int)post_value('prompt_id'); + $language = post_value('language', 'en'); + + $store->updatePrompt( + $user->id(), + $promptId, + post_value('name'), + $language, + post_value('content'), + isset($_POST['create_version']), + post_value('version_note') + ); + + $message = 'Prompt updated.'; + $mode = 'personal'; + } elseif ($action === 'create_version') { + $promptId = (int)post_value('prompt_id'); + $store->createVersion($user->id(), $promptId, post_value('version_note')); + $message = 'Version stored.'; + $mode = 'personal'; + } elseif ($action === 'restore_version') { + $promptId = (int)post_value('prompt_id'); + $versionNo = (int)post_value('version_no'); + $store->restoreVersion($user->id(), $promptId, $versionNo); + $message = 'Version restored.'; + $mode = 'personal'; + } elseif ($action === 'delete_version') { + $promptId = (int)post_value('prompt_id'); + $versionNo = (int)post_value('version_no'); + $store->deleteVersion($user->id(), $promptId, $versionNo); + $message = 'Version deleted.'; + $mode = 'personal'; + } elseif ($action === 'delete_prompt') { + $promptId = (int)post_value('prompt_id'); + $store->deletePrompt($user->id(), $promptId); + $message = 'Prompt deleted.'; + $mode = 'personal'; + } + + /* + * Admin-only default prompt actions. + */ + elseif ($action === 'create_default' || + $action === 'update_default' || + $action === 'create_default_version' || + $action === 'restore_default_version' || + $action === 'delete_default_version' || + $action === 'delete_default') { + + if (!$user->isAdmin()) { + throw new Exception('Admin rights required.'); + } + + $mode = 'defaults'; + + if ($action === 'create_default') { + $language = post_value('language', 'en'); + + $store->createDefaultPrompt( + post_value('default_key'), + post_value('name'), + $language, + post_value('content') + ); + + $message = 'Default prompt created.'; + } elseif ($action === 'update_default') { + $defaultId = (int)post_value('default_id'); + $language = post_value('language', 'en'); + + $store->updateDefaultPrompt( + $defaultId, + post_value('default_key'), + post_value('name'), + $language, + post_value('content'), + isset($_POST['create_version']), + post_value('version_note') + ); + + $message = 'Default prompt updated.'; + } elseif ($action === 'create_default_version') { + $defaultId = (int)post_value('default_id'); + $store->createDefaultVersion($defaultId, post_value('version_note')); + $message = 'Default prompt version stored.'; + } elseif ($action === 'restore_default_version') { + $defaultId = (int)post_value('default_id'); + $versionNo = (int)post_value('version_no'); + $store->restoreDefaultVersion($defaultId, $versionNo); + $message = 'Default prompt version restored.'; + } elseif ($action === 'delete_default_version') { + $defaultId = (int)post_value('default_id'); + $versionNo = (int)post_value('version_no'); + $store->deleteDefaultVersion($defaultId, $versionNo); + $message = 'Default prompt version deleted.'; + } elseif ($action === 'delete_default') { + $defaultId = (int)post_value('default_id'); + $store->deleteDefaultPrompt($defaultId); + $message = 'Default prompt deleted.'; + } + } elseif ($action !== '') { + throw new Exception('Unknown action: ' . $action); + } + } catch (Throwable $e) { + $error = $e->getMessage(); + } +} + +$personalPrompts = $store->listPrompts($user->id(), $language); +$defaultPrompts = $store->listDefaultPrompts($language); + +/* + * Full prompt data for the modal editor. + */ +$allPersonalPrompts = $store->listPrompts($user->id(), null); +$allDefaultPrompts = $store->listDefaultPrompts(null); + +$promptData = array( + 'personal' => array(), + 'default' => array(), + 'can_edit_defaults' => $user->isAdmin(), +); + +foreach ($allPersonalPrompts as $p) { + $promptData['personal'][$p->id()] = prompt_to_array($p); + $promptData['personal'][$p->id()]['versions'] = array(); + + foreach ($store->listVersions($user->id(), $p->id()) as $v) { + $promptData['personal'][$p->id()]['versions'][] = version_to_array($v); + } +} + +foreach ($allDefaultPrompts as $p) { + $promptData['default'][$p->id()] = prompt_to_array($p); + $promptData['default'][$p->id()]['versions'] = array(); + + if ($user->isAdmin()) { + foreach ($store->listDefaultVersions($p->id()) as $v) { + $promptData['default'][$p->id()]['versions'][] = version_to_array($v); + } + } +} + +$headerLanguages = array(); + +foreach ($store->supportedLanguages() as $lang) { + $headerLanguages[$lang] = $store->languageLabel($lang); +} + +$headerNavItems = array( + array( + 'label' => t('prompts.back', 'Back to Racket sandbox'), + 'url' => '/', + ), +); + +if ($user->isAdmin()) { + $headerNavItems[] = array( + 'label' => t('app.user_management', 'User management'), + 'url' => '/users?lang=' . rawurlencode($language), + 'separator_before' => true, + ); + $headerNavItems[] = array( + 'label' => t('app.configuration', 'Configuration'), + 'url' => '/admin-config?lang=' . rawurlencode($language), + 'separator_before' => true, + ); +} + +$styleVersion = @filemtime(__DIR__ . '/styles.css') ?: time(); +$promptEditorVersion = @filemtime(__DIR__ . '/prompt-editor.js') ?: time(); + +header('Content-Type: text/html; charset=utf-8'); +?> + + + + +<?= h(t('prompts.title', 'Prompt administration')) ?> + + + + +
+ + t('prompts.title', 'Prompt administration'), + 'nav_items' => $headerNavItems, + 'user' => $user, + 'admin_label' => t('app.admin', 'admin'), + 'language_label' => t('prompts.language', 'Language'), + 'language' => $language, + 'languages' => $headerLanguages, + 'language_action' => '/prompts', + 'language_hidden' => array('mode' => $mode), + 'logout_action' => '/prompts?lang=' . rawurlencode($language) . '&mode=' . rawurlencode($mode), + 'logout_label' => t('app.logout', 'Logout'), + 'message' => $message, + 'error' => $error, +)); +?> + +
+ + + +
+
+ +
+ +
+
+
+
+
+
+ +
+

+
+
+ +
+ +
+ +
+ +
+ + + + + + + + + + + diff --git a/promptstore.php b/promptstore.php new file mode 100644 index 0000000..9ca09ad --- /dev/null +++ b/promptstore.php @@ -0,0 +1,1075 @@ + 0 and is_default = 0: + * personal user prompt + */ + +class PromptStoreException extends Exception +{ +} + +class PromptRecord +{ + private $row; + + public function __construct($row) + { + $this->row = is_array($row) ? $row : array(); + } + + public function id() + { + return (int)($this->row['id'] ?? 0); + } + + public function userId() + { + return (int)($this->row['user_id'] ?? 0); + } + + public function isDefault() + { + return (int)($this->row['is_default'] ?? 0) === 1; + } + + public function defaultKey() + { + return (string)($this->row['default_key'] ?? ''); + } + + public function name() + { + return (string)($this->row['name'] ?? ''); + } + + public function language() + { + return (string)($this->row['language'] ?? ''); + } + + public function content() + { + return (string)($this->row['content'] ?? ''); + } + + public function createdAt() + { + return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null; + } + + public function updatedAt() + { + return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null; + } + + public function deletedAt() + { + return isset($this->row['deleted_at']) && $this->row['deleted_at'] !== null + ? (int)$this->row['deleted_at'] + : null; + } + + public function raw() + { + return $this->row; + } +} + +class PromptVersionRecord +{ + private $row; + + public function __construct($row) + { + $this->row = is_array($row) ? $row : array(); + } + + public function id() + { + return (int)($this->row['id'] ?? 0); + } + + public function promptId() + { + return (int)($this->row['prompt_id'] ?? 0); + } + + public function userId() + { + return (int)($this->row['user_id'] ?? 0); + } + + public function versionNo() + { + return (int)($this->row['version_no'] ?? 0); + } + + public function name() + { + return (string)($this->row['name'] ?? ''); + } + + public function language() + { + return (string)($this->row['language'] ?? ''); + } + + public function content() + { + return (string)($this->row['content'] ?? ''); + } + + public function note() + { + return (string)($this->row['note'] ?? ''); + } + + public function createdAt() + { + return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null; + } + + public function raw() + { + return $this->row; + } +} + +class PromptStore +{ + private $db; + + public function __construct($dbFile) + { + $dir = dirname($dbFile); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0700, true)) { + throw new PromptStoreException('Could not create database directory: ' . $dir); + } + } + + $this->db = new PDO('sqlite:' . $dbFile); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->db->exec('PRAGMA journal_mode = WAL'); + $this->db->exec('PRAGMA busy_timeout = 5000'); + + $this->init(); + } + + private function init() + { + /* + * Canonical schema. + * + * Existing databases are migrated below. In particular, this version + * does not require nullable user_id. Global defaults use user_id = 0. + */ + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS prompts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0, + default_key TEXT NULL, + name TEXT NOT NULL, + language TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER NULL + )' + ); + + $this->migratePromptsTable(); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_prompts_user + ON prompts(user_id, is_default, deleted_at, language, name)' + ); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_prompts_default + ON prompts(is_default, deleted_at, language, default_key)' + ); + + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS prompt_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_id INTEGER NOT NULL, + user_id INTEGER NOT NULL DEFAULT 0, + version_no INTEGER NOT NULL, + name TEXT NOT NULL, + language TEXT NOT NULL, + content TEXT NOT NULL, + note TEXT NOT NULL DEFAULT "", + created_at INTEGER NOT NULL, + FOREIGN KEY(prompt_id) REFERENCES prompts(id), + UNIQUE(prompt_id, version_no) + )' + ); + + $this->migratePromptVersionsTable(); + + $this->db->exec( + 'CREATE INDEX IF NOT EXISTS idx_prompt_versions_prompt + ON prompt_versions(prompt_id, version_no)' + ); + } + + private function migratePromptsTable() + { + $cols = $this->tableColumns('prompts'); + + if (!isset($cols['user_id'])) { + $this->db->exec('ALTER TABLE prompts ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0'); + } + + if (!isset($cols['is_default'])) { + $this->db->exec('ALTER TABLE prompts ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0'); + } + + if (!isset($cols['default_key'])) { + $this->db->exec('ALTER TABLE prompts ADD COLUMN default_key TEXT NULL'); + } + + if (!isset($cols['deleted_at'])) { + $this->db->exec('ALTER TABLE prompts ADD COLUMN deleted_at INTEGER NULL'); + } + + /* + * Normalize existing rows. + */ + $this->db->exec( + 'UPDATE prompts + SET is_default = 0 + WHERE is_default IS NULL' + ); + + $this->db->exec( + 'UPDATE prompts + SET user_id = 0 + WHERE is_default = 1' + ); + } + + private function migratePromptVersionsTable() + { + $cols = $this->tableColumns('prompt_versions'); + + if (!isset($cols['user_id'])) { + $this->db->exec('ALTER TABLE prompt_versions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0'); + } + + if (!isset($cols['note'])) { + $this->db->exec('ALTER TABLE prompt_versions ADD COLUMN note TEXT NOT NULL DEFAULT ""'); + } + + $this->db->exec( + 'UPDATE prompt_versions + SET user_id = 0 + WHERE user_id IS NULL' + ); + } + + private function tableColumns($table) + { + $stmt = $this->db->query('PRAGMA table_info(' . $table . ')'); + $cols = array(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $cols[$row['name']] = true; + } + + return $cols; + } + + public function supportedLanguages() + { + return array('en', 'nl'); + } + + public function languageLabel($language) + { + if ($language === 'nl') { + return 'Nederlands'; + } + + if ($language === 'en') { + return 'English'; + } + + return $language; + } + + /* ------------------------------------------------------------------ */ + /* Personal prompts */ + /* ------------------------------------------------------------------ */ + + public function listPrompts($userId, $language = null) + { + $userId = $this->safeUserId($userId); + + if ($language !== null && $language !== '') { + $language = $this->safeLanguage($language); + + $stmt = $this->db->prepare( + 'SELECT * + FROM prompts + WHERE user_id = :user_id + AND is_default = 0 + AND deleted_at IS NULL + AND language = :language + ORDER BY name COLLATE NOCASE' + ); + + $stmt->execute(array( + ':user_id' => $userId, + ':language' => $language, + )); + } else { + $stmt = $this->db->prepare( + 'SELECT * + FROM prompts + WHERE user_id = :user_id + AND is_default = 0 + AND deleted_at IS NULL + ORDER BY language, name COLLATE NOCASE' + ); + + $stmt->execute(array(':user_id' => $userId)); + } + + return $this->promptRows($stmt); + } + + public function getPrompt($userId, $promptId) + { + $userId = $this->safeUserId($userId); + $promptId = $this->safeId($promptId); + + $stmt = $this->db->prepare( + 'SELECT * + FROM prompts + WHERE id = :id + AND user_id = :user_id + AND is_default = 0 + AND deleted_at IS NULL' + ); + + $stmt->execute(array( + ':id' => $promptId, + ':user_id' => $userId, + )); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? new PromptRecord($row) : null; + } + + public function createPrompt($userId, $name, $language, $content) + { + $userId = $this->safeUserId($userId); + $name = $this->safeName($name); + $language = $this->safeLanguage($language); + $content = $this->safeContent($content); + + $now = time(); + + $stmt = $this->db->prepare( + 'INSERT INTO prompts + (user_id, is_default, default_key, name, language, content, + created_at, updated_at, deleted_at) + VALUES + (:user_id, 0, NULL, :name, :language, :content, + :created_at, :updated_at, NULL)' + ); + + $stmt->execute(array( + ':user_id' => $userId, + ':name' => $name, + ':language' => $language, + ':content' => $content, + ':created_at' => $now, + ':updated_at' => $now, + )); + + $promptId = (int)$this->db->lastInsertId(); + $this->createVersionForPrompt($promptId, $userId, 'initial version'); + + return $this->getPrompt($userId, $promptId); + } + + public function updatePrompt($userId, $promptId, $name, $language, $content, $createVersion, $note = '') + { + $userId = $this->safeUserId($userId); + $promptId = $this->safeId($promptId); + $name = $this->safeName($name); + $language = $this->safeLanguage($language); + $content = $this->safeContent($content); + + $prompt = $this->getPrompt($userId, $promptId); + + if ($prompt === null) { + throw new PromptStoreException('Prompt not found.'); + } + + $stmt = $this->db->prepare( + 'UPDATE prompts + SET name = :name, + language = :language, + content = :content, + updated_at = :updated_at + WHERE id = :id + AND user_id = :user_id + AND is_default = 0 + AND deleted_at IS NULL' + ); + + $stmt->execute(array( + ':name' => $name, + ':language' => $language, + ':content' => $content, + ':updated_at' => time(), + ':id' => $promptId, + ':user_id' => $userId, + )); + + if ($createVersion) { + $this->createVersionForPrompt($promptId, $userId, $note); + } + + return $this->getPrompt($userId, $promptId); + } + + public function deletePrompt($userId, $promptId) + { + $userId = $this->safeUserId($userId); + $promptId = $this->safeId($promptId); + + $stmt = $this->db->prepare( + 'UPDATE prompts + SET deleted_at = :deleted_at, + updated_at = :updated_at + WHERE id = :id + AND user_id = :user_id + AND is_default = 0 + AND deleted_at IS NULL' + ); + + $stmt->execute(array( + ':deleted_at' => time(), + ':updated_at' => time(), + ':id' => $promptId, + ':user_id' => $userId, + )); + } + + /* ------------------------------------------------------------------ */ + /* Default prompts */ + /* ------------------------------------------------------------------ */ + + public function listDefaultPrompts($language = null) + { + if ($language !== null && $language !== '') { + $language = $this->safeLanguage($language); + + $stmt = $this->db->prepare( + 'SELECT * + FROM prompts + WHERE user_id = 0 + AND is_default = 1 + AND deleted_at IS NULL + AND language = :language + ORDER BY name COLLATE NOCASE' + ); + + $stmt->execute(array(':language' => $language)); + } else { + $stmt = $this->db->query( + 'SELECT * + FROM prompts + WHERE user_id = 0 + AND is_default = 1 + AND deleted_at IS NULL + ORDER BY language, name COLLATE NOCASE' + ); + } + + return $this->promptRows($stmt); + } + + public function getDefaultPrompt($promptId) + { + $promptId = $this->safeId($promptId); + + $stmt = $this->db->prepare( + 'SELECT * + FROM prompts + WHERE id = :id + AND user_id = 0 + AND is_default = 1 + AND deleted_at IS NULL' + ); + + $stmt->execute(array(':id' => $promptId)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? new PromptRecord($row) : null; + } + + public function createDefaultPrompt($defaultKey, $name, $language, $content) + { + $defaultKey = $this->safeDefaultKey($defaultKey); + $name = $this->safeName($name); + $language = $this->safeLanguage($language); + $content = $this->safeContent($content); + + $now = time(); + + $stmt = $this->db->prepare( + 'INSERT INTO prompts + (user_id, is_default, default_key, name, language, content, + created_at, updated_at, deleted_at) + VALUES + (0, 1, :default_key, :name, :language, :content, + :created_at, :updated_at, NULL)' + ); + + $stmt->execute(array( + ':default_key' => $defaultKey, + ':name' => $name, + ':language' => $language, + ':content' => $content, + ':created_at' => $now, + ':updated_at' => $now, + )); + + $promptId = (int)$this->db->lastInsertId(); + $this->createVersionForPrompt($promptId, 0, 'initial default version'); + + return $this->getDefaultPrompt($promptId); + } + + public function updateDefaultPrompt($promptId, $defaultKey, $name, $language, $content, $createVersion, $note = '') + { + $promptId = $this->safeId($promptId); + $defaultKey = $this->safeDefaultKey($defaultKey); + $name = $this->safeName($name); + $language = $this->safeLanguage($language); + $content = $this->safeContent($content); + + $prompt = $this->getDefaultPrompt($promptId); + + if ($prompt === null) { + throw new PromptStoreException('Default prompt not found.'); + } + + $stmt = $this->db->prepare( + 'UPDATE prompts + SET default_key = :default_key, + name = :name, + language = :language, + content = :content, + updated_at = :updated_at + WHERE id = :id + AND user_id = 0 + AND is_default = 1 + AND deleted_at IS NULL' + ); + + $stmt->execute(array( + ':default_key' => $defaultKey, + ':name' => $name, + ':language' => $language, + ':content' => $content, + ':updated_at' => time(), + ':id' => $promptId, + )); + + if ($createVersion) { + $this->createVersionForPrompt($promptId, 0, $note); + } + + return $this->getDefaultPrompt($promptId); + } + + public function deleteDefaultPrompt($promptId) + { + $promptId = $this->safeId($promptId); + + $stmt = $this->db->prepare( + 'UPDATE prompts + SET deleted_at = :deleted_at, + updated_at = :updated_at + WHERE id = :id + AND user_id = 0 + AND is_default = 1 + AND deleted_at IS NULL' + ); + + $stmt->execute(array( + ':deleted_at' => time(), + ':updated_at' => time(), + ':id' => $promptId, + )); + } + + public function copyDefaultPromptToUser($userId, $defaultPromptId) + { + $userId = $this->safeUserId($userId); + $defaultPromptId = $this->safeId($defaultPromptId); + + $default = $this->getDefaultPrompt($defaultPromptId); + + if ($default === null) { + throw new PromptStoreException('Default prompt not found.'); + } + + $now = time(); + + $stmt = $this->db->prepare( + 'INSERT INTO prompts + (user_id, is_default, default_key, name, language, content, + created_at, updated_at, deleted_at) + VALUES + (:user_id, 0, :default_key, :name, :language, :content, + :created_at, :updated_at, NULL)' + ); + + $stmt->execute(array( + ':user_id' => $userId, + ':default_key' => $default->defaultKey(), + ':name' => $default->name(), + ':language' => $default->language(), + ':content' => $default->content(), + ':created_at' => $now, + ':updated_at' => $now, + )); + + $promptId = (int)$this->db->lastInsertId(); + $this->createVersionForPrompt($promptId, $userId, 'copied from default ' . $default->defaultKey()); + + return $this->getPrompt($userId, $promptId); + } + + public function copyAllDefaultsToUser($userId, $language) + { + $userId = $this->safeUserId($userId); + $language = $this->safeLanguage($language); + + $defaults = $this->listDefaultPrompts($language); + $created = 0; + + foreach ($defaults as $default) { + if ($this->userHasPromptForDefault($userId, $default->defaultKey(), $language)) { + continue; + } + + $this->copyDefaultPromptToUser($userId, $default->id()); + $created++; + } + + return $created; + } + + public function userHasPromptForDefault($userId, $defaultKey, $language) + { + $userId = $this->safeUserId($userId); + $defaultKey = $this->safeDefaultKey($defaultKey); + $language = $this->safeLanguage($language); + + $stmt = $this->db->prepare( + 'SELECT id + FROM prompts + WHERE user_id = :user_id + AND is_default = 0 + AND deleted_at IS NULL + AND default_key = :default_key + AND language = :language' + ); + + $stmt->execute(array( + ':user_id' => $userId, + ':default_key' => $defaultKey, + ':language' => $language, + )); + + return $stmt->fetch(PDO::FETCH_ASSOC) ? true : false; + } + + /* ------------------------------------------------------------------ */ + /* Versions */ + /* ------------------------------------------------------------------ */ + + public function createVersion($userId, $promptId, $note = '') + { + $userId = $this->safeUserId($userId); + $promptId = $this->safeId($promptId); + + if ($this->getPrompt($userId, $promptId) === null) { + throw new PromptStoreException('Prompt not found.'); + } + + return $this->createVersionForPrompt($promptId, $userId, $note); + } + + public function createDefaultVersion($promptId, $note = '') + { + $promptId = $this->safeId($promptId); + + if ($this->getDefaultPrompt($promptId) === null) { + throw new PromptStoreException('Default prompt not found.'); + } + + return $this->createVersionForPrompt($promptId, 0, $note); + } + + public function listVersions($userId, $promptId) + { + $userId = $this->safeUserId($userId); + $promptId = $this->safeId($promptId); + + if ($this->getPrompt($userId, $promptId) === null) { + throw new PromptStoreException('Prompt not found.'); + } + + return $this->listVersionsForPrompt($promptId); + } + + public function listDefaultVersions($promptId) + { + $promptId = $this->safeId($promptId); + + if ($this->getDefaultPrompt($promptId) === null) { + throw new PromptStoreException('Default prompt not found.'); + } + + return $this->listVersionsForPrompt($promptId); + } + + public function restoreVersion($userId, $promptId, $versionNo) + { + $userId = $this->safeUserId($userId); + $promptId = $this->safeId($promptId); + + if ($this->getPrompt($userId, $promptId) === null) { + throw new PromptStoreException('Prompt not found.'); + } + + $this->restoreVersionForPrompt($promptId, $versionNo, $userId); + } + + public function restoreDefaultVersion($promptId, $versionNo) + { + $promptId = $this->safeId($promptId); + + if ($this->getDefaultPrompt($promptId) === null) { + throw new PromptStoreException('Default prompt not found.'); + } + + $this->restoreVersionForPrompt($promptId, $versionNo, 0); + } + + private function createVersionForPrompt($promptId, $userId, $note = '') + { + $promptId = $this->safeId($promptId); + $userId = (int)$userId; + $note = trim((string)$note); + + if (strlen($note) > 500) { + $note = substr($note, 0, 500); + } + + $stmt = $this->db->prepare( + 'SELECT * + FROM prompts + WHERE id = :id + AND deleted_at IS NULL' + ); + + $stmt->execute(array(':id' => $promptId)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new PromptStoreException('Prompt not found.'); + } + + $stmt = $this->db->prepare( + 'SELECT COALESCE(MAX(version_no), 0) + 1 AS next_no + FROM prompt_versions + WHERE prompt_id = :prompt_id' + ); + + $stmt->execute(array(':prompt_id' => $promptId)); + $versionRow = $stmt->fetch(PDO::FETCH_ASSOC); + $versionNo = (int)$versionRow['next_no']; + + $stmt = $this->db->prepare( + 'INSERT INTO prompt_versions + (prompt_id, user_id, version_no, name, language, content, note, created_at) + VALUES + (:prompt_id, :user_id, :version_no, :name, :language, :content, :note, :created_at)' + ); + + $stmt->execute(array( + ':prompt_id' => $promptId, + ':user_id' => $userId, + ':version_no' => $versionNo, + ':name' => $row['name'], + ':language' => $row['language'], + ':content' => $row['content'], + ':note' => $note, + ':created_at' => time(), + )); + + return $versionNo; + } + + private function listVersionsForPrompt($promptId) + { + $promptId = $this->safeId($promptId); + + $stmt = $this->db->prepare( + 'SELECT * + FROM prompt_versions + WHERE prompt_id = :prompt_id + ORDER BY version_no DESC' + ); + + $stmt->execute(array(':prompt_id' => $promptId)); + + $out = array(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = new PromptVersionRecord($row); + } + + return $out; + } + + private function restoreVersionForPrompt($promptId, $versionNo, $userId) + { + $promptId = $this->safeId($promptId); + $versionNo = (int)$versionNo; + $userId = (int)$userId; + + if ($versionNo <= 0) { + throw new PromptStoreException('Invalid version number.'); + } + + $stmt = $this->db->prepare( + 'SELECT * + FROM prompt_versions + WHERE prompt_id = :prompt_id + AND version_no = :version_no' + ); + + $stmt->execute(array( + ':prompt_id' => $promptId, + ':version_no' => $versionNo, + )); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new PromptStoreException('Version not found.'); + } + + $stmt = $this->db->prepare( + 'UPDATE prompts + SET name = :name, + language = :language, + content = :content, + updated_at = :updated_at + WHERE id = :id + AND deleted_at IS NULL' + ); + + $stmt->execute(array( + ':name' => $row['name'], + ':language' => $row['language'], + ':content' => $row['content'], + ':updated_at' => time(), + ':id' => $promptId, + )); + + $this->createVersionForPrompt($promptId, $userId, 'restored version ' . $versionNo); + } + + /* ------------------------------------------------------------------ */ + /* Helpers */ + /* ------------------------------------------------------------------ */ + + private function promptRows($stmt) + { + $out = array(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = new PromptRecord($row); + } + + return $out; + } + + private function safeUserId($userId) + { + $userId = (int)$userId; + + if ($userId <= 0) { + throw new PromptStoreException('Invalid user id.'); + } + + return $userId; + } + + private function safeId($id) + { + $id = (int)$id; + + if ($id <= 0) { + throw new PromptStoreException('Invalid id.'); + } + + return $id; + } + + private function safeName($name) + { + $name = trim((string)$name); + + if ($name === '') { + throw new PromptStoreException('Prompt name is required.'); + } + + if (strlen($name) > 160) { + throw new PromptStoreException('Prompt name is too long.'); + } + + return $name; + } + + private function safeLanguage($language) + { + $language = strtolower(trim((string)$language)); + + if (!in_array($language, $this->supportedLanguages(), true)) { + throw new PromptStoreException('Unsupported language: ' . $language); + } + + return $language; + } + + private function safeContent($content) + { + $content = trim((string)$content); + + if ($content === '') { + throw new PromptStoreException('Prompt content is required.'); + } + + if (strlen($content) > 200000) { + throw new PromptStoreException('Prompt content is too large.'); + } + + return $content; + } + + private function safeDefaultKey($key) + { + $key = trim((string)$key); + + if ($key === '') { + throw new PromptStoreException('Default key is required.'); + } + + if (!preg_match('/^[A-Za-z0-9_-]+$/', $key)) { + throw new PromptStoreException('Invalid default key.'); + } + + return $key; + } + +public function deleteVersion($userId, $promptId, $versionNo) +{ + $userId = $this->safeUserId($userId); + $promptId = $this->safeId($promptId); + $versionNo = (int)$versionNo; + + if ($versionNo <= 0) { + throw new PromptStoreException('Invalid version number.'); + } + + if ($this->getPrompt($userId, $promptId) === null) { + throw new PromptStoreException('Prompt not found.'); + } + + $this->deleteVersionForPrompt($promptId, $versionNo); +} + +public function deleteDefaultVersion($promptId, $versionNo) +{ + $promptId = $this->safeId($promptId); + $versionNo = (int)$versionNo; + + if ($versionNo <= 0) { + throw new PromptStoreException('Invalid version number.'); + } + + if ($this->getDefaultPrompt($promptId) === null) { + throw new PromptStoreException('Default prompt not found.'); + } + + $this->deleteVersionForPrompt($promptId, $versionNo); +} + +private function deleteVersionForPrompt($promptId, $versionNo) +{ + $promptId = $this->safeId($promptId); + $versionNo = (int)$versionNo; + + $stmt = $this->db->prepare( + 'SELECT COUNT(*) AS n + FROM prompt_versions + WHERE prompt_id = :prompt_id' + ); + + $stmt->execute(array(':prompt_id' => $promptId)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row || (int)$row['n'] <= 1) { + throw new PromptStoreException('Cannot delete the last remaining version.'); + } + + $stmt = $this->db->prepare( + 'DELETE FROM prompt_versions + WHERE prompt_id = :prompt_id + AND version_no = :version_no' + ); + + $stmt->execute(array( + ':prompt_id' => $promptId, + ':version_no' => $versionNo, + )); + + if ($stmt->rowCount() !== 1) { + throw new PromptStoreException('Version not found.'); + } +} +} \ No newline at end of file diff --git a/racketzip.php b/racketzip.php new file mode 100644 index 0000000..20890c8 --- /dev/null +++ b/racketzip.php @@ -0,0 +1,196 @@ + $n, + 'name' => $name, + 'file' => $file, + 'size' => $binaryBytes, + ); + + $totalBinaryBytes += $binaryBytes; + $nr++; + } + + fclose($in); + + if (count($parts) === 0) { + throw new RuntimeException('racket.zip lijkt leeg te zijn.'); + } + + $manifest = array( + 'source_file' => RACKET_ZIP_FILE, + 'source_bytes' => filesize(RACKET_ZIP_FILE), + 'source_mtime' => filemtime(RACKET_ZIP_FILE), + 'source_sha256' => hash_file('sha256', RACKET_ZIP_FILE), + 'max_base64_kib' => (int)$maxBase64Kib, + 'max_base64_bytes' => $maxBase64Bytes, + 'binary_chunk_bytes' => $binaryChunkBytes, + 'part_count' => count($parts), + 'total_binary_bytes' => $totalBinaryBytes, + 'parts' => $parts, + 'created_at' => gmdate('c'), + ); + + if (file_put_contents( + RACKET_ZIP_MANIFEST_FILE, + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", + LOCK_EX + ) === false) { + throw new RuntimeException('Kan racket.zip manifest niet schrijven: ' . RACKET_ZIP_MANIFEST_FILE); + } + + return $manifest; +} + +function load_racket_zip_parts_manifest() +{ + if (!is_file(RACKET_ZIP_MANIFEST_FILE) || !is_readable(RACKET_ZIP_MANIFEST_FILE)) { + throw new RuntimeException('racket.zip parts-manifest ontbreekt: ' . RACKET_ZIP_MANIFEST_FILE); + } + + $raw = file_get_contents(RACKET_ZIP_MANIFEST_FILE); + + if ($raw === false) { + throw new RuntimeException('Kan racket.zip parts-manifest niet lezen: ' . RACKET_ZIP_MANIFEST_FILE); + } + + $manifest = json_decode($raw, true); + + if (!is_array($manifest)) { + throw new RuntimeException('racket.zip parts-manifest is geen geldige JSON: ' . RACKET_ZIP_MANIFEST_FILE); + } + + return $manifest; +} + +function racket_zip_parts_current($manifest, $maxBase64Kib) +{ + if (!is_array($manifest)) { + return false; + } + + if ((int)($manifest['max_base64_kib'] ?? 0) !== (int)$maxBase64Kib) { + return false; + } + + if (!is_file(RACKET_ZIP_FILE)) { + return false; + } + + if ((int)($manifest['source_bytes'] ?? -1) !== (int)filesize(RACKET_ZIP_FILE)) { + return false; + } + + if ((int)($manifest['source_mtime'] ?? -1) !== (int)filemtime(RACKET_ZIP_FILE)) { + return false; + } + + foreach (($manifest['parts'] ?? array()) as $part) { + $number = (string)($part['number'] ?? ''); + $file = racket_zip_part_file($number); + + if (!preg_match('/^[0-9]{6}$/', $number) || !is_file($file) || !is_readable($file)) { + return false; + } + } + + return count($manifest['parts'] ?? array()) > 0; +} diff --git a/rkt.php b/rkt.php new file mode 100644 index 0000000..a7c72c6 --- /dev/null +++ b/rkt.php @@ -0,0 +1,312 @@ +create(); + +function path_only() +{ + $p = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); + $p = '/' . trim($p ?: '/', '/'); + return $p === '/' ? '/' : $p; +} + +function scheme() +{ + if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0])); + } + + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +} + +function host_name() +{ + return $_SERVER['HTTP_HOST'] ?? 'localhost'; +} + +function make_url($path, $query = array()) +{ + global $NEXT_ID; + + $query['next'] = $NEXT_ID; + + return scheme() . '://' . host_name() . $path . '?' . http_build_query($query); +} + +function h($s) +{ + return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function html_response($html, $status = 200) +{ + http_response_code($status); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + echo $html; + exit; +} + +function text_response($text, $status = 200) +{ + http_response_code($status); + header('Content-Type: text/plain; charset=us-ascii'); + header('Content-Disposition: inline'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + + echo $text; + + if ($text === '' || substr($text, -1) !== "\n") { + echo "\n"; + } + + exit; +} + +function fail_html($message, $status = 500) +{ + html_response( + '' . + 'Error' . + '

Error

' . h($message) . '
', + $status + ); +} + +function fail_text($message, $status = 500) +{ + text_response("error: " . $message . "\n", $status); +} + +function load_parts() +{ + try { + $manifest = load_racket_zip_parts_manifest(); + } catch (Throwable $e) { + fail_html($e->getMessage() . "\n\nSla de configuratiepagina op om racket.zip in parts te verdelen."); + } + + if (!racket_zip_parts_current($manifest, RACKET_ZIP_MAX_BASE64_KB)) { + fail_html( + "racket.zip parts ontbreken of passen niet bij de huidige chunk-grootte.\n\n" . + "Sla de configuratiepagina op om de parts opnieuw te maken." + ); + } + + $parts = array(); + + foreach (($manifest['parts'] ?? array()) as $part) { + $number = (string)($part['number'] ?? ''); + $parts[] = array( + 'number' => $number, + 'name' => (string)($part['name'] ?? racket_zip_part_name((int)$number)), + 'size' => (int)($part['size'] ?? 0), + 'url' => make_url('/bootstrap-racket-part', array('n' => $number)), + ); + } + + $manifest['parts'] = $parts; + + return $manifest; +} + +function serve_bootstrap() +{ + global $NEXT_ID; + + $manifest = load_parts(); + $parts = $manifest['parts']; + + $rows = ''; + + foreach ($parts as $i => $part) { + $rows .= + '' . + '' . h((string)($i + 1)) . '' . + '' . h($part['number']) . '' . + '' . h((string)$part['size']) . '' . + '' . h($part['url']) . '' . + '' . "\n"; + } + + $zipSize = (int)($manifest['source_bytes'] ?? 0); + + $pkg_url = make_url('/racket-pkg-index'); + + html_response(' + + + +Racket bootstrap + + + + +

Racket bootstrap

+ +

+racket.zip is vooraf via de configuratiepagina gesplitst naar +parts in de map data. +

+ +

+Package index: Racket package index +

+ +

+Bronbestand: config/racket.zip
+Bronbestand bytes: ' . h((string)$zipSize) . '
+Maximale base64 part-grootte: ' . h((string)RACKET_ZIP_MAX_BASE64_KB) . ' KiB (' . h((string)RACKET_ZIP_MAX_BASE64_BYTES) . ' bytes)
+Binaire chunk-grootte: ' . h((string)RACKET_ZIP_BINARY_CHUNK_BYTES) . ' bytes
+Aantal parts: ' . h((string)count($parts)) . '
+Parts gemaakt op: ' . h((string)($manifest['created_at'] ?? '')) . '
+next-id voor alle links op deze pagina: ' . h($NEXT_ID) . ' +

+ +

+Elke link hieronder geeft text/plain met de +base64-representatie van één binair part. +De URL bevat alleen een nummer, geen bestandsnaam en geen extensie. +

+ + + + + + + + + + + +' . $rows . ' + +
#partnummerbytesbase64 text/plain URL
+ +

Reconstructie in de sandbox

+ +

+Decodeer ieder base64-part afzonderlijk naar een binair part. Plak daarna de +binaire parts in numerieke volgorde aan elkaar. +

+ +
+base64 -d part-000001.txt > part-000001
+base64 -d part-000002.txt > part-000002
+base64 -d part-000003.txt > part-000003
+# enzovoort
+
+cat part-* > racket.zip
+unzip racket.zip -d /tmp/racket
+
+ + +'); +} + +function serve_part() +{ + $n = $_GET['n'] ?? ''; + + if (!is_string($n) || !preg_match('/^[0-9]{6}$/', $n)) { + fail_text('ongeldig partnummer; gebruik bijvoorbeeld n=000001', 400); + } + + $file = racket_zip_part_file($n); + + if (!is_file($file) || !is_readable($file)) { + fail_text('part ontbreekt of is niet leesbaar: ' . $n, 404); + } + + $bin = file_get_contents($file); + + if ($bin === false) { + fail_text('kan part niet lezen: ' . $n, 500); + } + + text_response(base64_encode($bin)); +} + +$path = path_only(); + +if ($path === '/' || $path === '/index.php') { + header('Location: ' . make_url('/bootstrap-racket'), true, 302); + exit; +} + +if ($path === '/bootstrap-racket') { + $TOKENS->check_valid_next('html'); + serve_bootstrap(); +} + +if ($path === '/bootstrap-racket-part') { + $TOKENS->check_valid_next('text'); + serve_part(); +} + +fail_html('Onbekende route: ' . $path, 404); diff --git a/rktpkgs.php b/rktpkgs.php new file mode 100644 index 0000000..bb2ebb3 --- /dev/null +++ b/rktpkgs.php @@ -0,0 +1,187 @@ +&next= + * + * Regels: + * - Geen filterveld. + * - Geen git-adressen tonen. + * - Geen package-details tonen. + * - Geen package-downloads implementeren in dit script. + * - De package-naam zelf is de link. + * - Eén next-id per gegenereerde HTML-pagina. + * + * .htaccess: + * + * Options -MultiViews + * RewriteEngine On + * RewriteRule ^racket-pkg-index$ rktpkgs.php [L,QSA] + */ + +ini_set('display_errors', '1'); +ini_set('display_startup_errors', '1'); +ini_set('log_errors', '1'); +error_reporting(E_ALL); + +require_once __DIR__ . '/nexttoken.php'; +require_once __DIR__ . '/lib/catalog-http.php'; +require_once __DIR__ . '/lib/racket-data.php'; + +$TOKENS = new NextTokenStore(__DIR__ . '/data/racket-sandbox.sqlite'); + +@set_time_limit(180); +ignore_user_abort(false); + +define('CATALOG_PKGS_ALL', 'https://pkgs.racket-lang.org/pkgs-all'); + +define('CACHE_DIR', __DIR__ . '/data'); +define('CACHE_FILE', CACHE_DIR . '/pkgs-all.rktd'); +define('CACHE_META_FILE', CACHE_DIR . '/pkgs-all.meta.json'); +define('CACHE_TTL', 3600); + +/* + * Eén next-id voor alle package-links op deze pagina. + */ +$NEXT_ID = $TOKENS->create(); + +function h($s) +{ + return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function current_scheme() +{ + if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0])); + } + + return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +} + +function current_host() +{ + return $_SERVER['HTTP_HOST'] ?? 'localhost'; +} + +function make_url($path, $query = array()) +{ + global $NEXT_ID; + + $query['next'] = $NEXT_ID; + + return current_scheme() . '://' . current_host() . $path . '?' . http_build_query($query); +} + +function html_response($html, $status = 200) +{ + http_response_code($status); + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + + echo $html; + exit; +} + +function fail_html($message, $status = 500) +{ + html_response( + '' . + 'Error' . + '

Error

' . h($message) . '
', + $status + ); +} + +function get_pkgs_all_text() +{ + try { + return catalog_http_fetch_cached( + CATALOG_PKGS_ALL, + CACHE_FILE, + CACHE_META_FILE, + CACHE_TTL, + 'rktsndbx-rktpkgs/1.0', + 180 + ); + } catch (Throwable $e) { + fail_html($e->getMessage()); + } +} + +function serve_index() +{ + global $NEXT_ID; + + $text = get_pkgs_all_text(); + $names = rktd_extract_top_level_package_names($text); + + if (count($names) === 0) { + fail_html('Geen package-namen gevonden in pkgs-all.'); + } + + $rows = ''; + + foreach ($names as $i => $name) { + $url = make_url('/package', array('name' => $name)); + + $rows .= + '' . + '' . h((string)($i + 1)) . '' . + '' . + '' . h($name) . '' . + '' . + '' . "\n"; + } + + html_response(' + + + +Racket package index + + + + +

Racket package index

+ +

+Volledige HTML-index van de Racket package catalogus op basis van +pkgs-all. De package-naam is de ophaallink via +rktsndbx.dijkewijk.nl. +

+ +

+Aantal packages: ' . h((string)count($names)) . '
+next-id voor alle package-links op deze pagina: +' . h($NEXT_ID) . ' +

+ + + + + + + + + +' . $rows . ' + +
#package
+ + +'); +} + +$TOKENS->check_valid_next('html'); +serve_index(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..2cb3fe5 --- /dev/null +++ b/styles.css @@ -0,0 +1,932 @@ +html, +body { + height: 100%; + margin: 0; +} + +body { + font-family: Verdana, Arial, sans-serif; + font-size: 14px; + line-height: 1.45; + overflow: hidden; + color: #242424; + background: #e7e7e7; +} + +body.simple-doc { + height: auto; + min-height: 100%; + overflow: auto; + margin: 2rem; + background: #fff; +} + +body.simple-doc table { + margin: 0.75rem 0 1.5rem 0; +} + +body.simple-doc pre { + background: #f5f5f5; + padding: 8px; + overflow: auto; +} + +body.login-page form { + max-width: 28rem; +} + +body.login-page .login-layout { + display: flex; + align-items: flex-start; + gap: 2rem; + max-width: 58rem; +} + +body.login-page .login-panel { + flex: 1 1 28rem; +} + +body.login-page .login-request-panel { + flex: 0 1 22rem; + border-left: 1px solid #ddd; + padding-left: 1.5rem; +} + +body.login-page .login-request-panel h2 { + margin-top: 0.35rem; +} + +body.login-page label { + display: block; + margin: 0.7rem 0; +} + +body.login-page input[type=email], +body.login-page input[type=password] { + width: 100%; + box-sizing: border-box; +} + +body.login-page button { + margin-top: 0.7rem; +} + +@media (max-width: 720px) { + body.login-page .login-layout { + flex-direction: column; + gap: 1rem; + } + + body.login-page .login-request-panel { + border-left: none; + border-top: 1px solid #ddd; + padding-left: 0; + padding-top: 1rem; + } +} + +.pkg-name, +.pkg-link { + font-family: Consolas, "Courier New", monospace; + white-space: nowrap; +} + +.page { + height: 100vh; + display: grid; + grid-template-rows: auto 1fr auto; + padding: 1rem; + box-sizing: border-box; + gap: 0.6rem; +} + +.page-header { + min-height: 0; +} + +.page-titlebar { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 1rem; + padding: 0.7rem 0.85rem; + border: 1px solid #c8c8c8; + background: #f4f4f4; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset; +} + +.page-titlebar h1 { + margin: 0; + white-space: nowrap; + color: #303030; + font-size: 1.45rem; +} + +.header-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.35rem 0.45rem; + min-width: 0; +} + +.header-nav a, +.header-nav strong, +.nav-user { + white-space: nowrap; +} + +.nav-separator { + color: #8a8a8a; + line-height: 1; +} + +.header-nav a, +.header-nav strong { + display: inline-block; + padding: 0.32rem 0.65rem; + border: 1px solid #aaa; + border-radius: 4px; + background: #ededed; + color: #252525; + text-decoration: none; + line-height: 1.2; +} + +.header-nav a:hover, +.header-nav a:focus { + background: #dedede; + border-color: #888; +} + +.header-nav strong { + background: #555; + border-color: #555; + color: #fff; +} + +.nav-user { + color: #454545; +} + +.header-language-form { + margin: 0; +} + +.header-action-form { + margin: 0; +} + +.header-action-form button { + margin: 0; +} + +.header-language-form label { + display: flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.header-language-form select { + padding: 0.25rem 0.4rem; + border: 1px solid #aaa; + border-radius: 4px; + background: #fff; +} + +.page-main { + min-height: 0; + overflow: auto; + display: grid; + gap: 0.8rem; +} + +.page-footer { + border-top: 1px solid #ddd; + padding-top: 0.4rem; +} + +fieldset { + border: 1px solid #ccc; + padding: 0.8rem; + margin: 0.75rem 0 0 0; +} + +legend { + font-weight: bold; +} + +label { + display: block; + margin: 0.35rem 0; +} + +input[type=password], +input[type=number] { + width: 28rem; + max-width: 100%; + box-sizing: border-box; +} + +.copy-box { + width: 100%; + box-sizing: border-box; +} + +.generated-link-row { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 0.55rem; +} + +.bootstrap-result-grid { + display: grid; + grid-template-columns: minmax(18rem, 0.9fr) minmax(22rem, 1.1fr); + gap: 1rem; + align-items: start; +} + +.generated-link-row a { + max-width: min(72vw, 70rem); + overflow-wrap: anywhere; + padding: 0.45rem 0.55rem; + border: 1px solid #ccc; + background: #fff; +} + +.bootstrap-prompt-tool { + display: grid; + gap: 0.55rem; + max-width: min(76vw, 72rem); +} + +.bootstrap-prompt-tool select, +.bootstrap-prompt-tool textarea { + width: 100%; + box-sizing: border-box; +} + +.bootstrap-prompt-tool textarea { + min-height: 14rem; + resize: vertical; +} + +@media (max-width: 720px) { + .bootstrap-result-grid { + grid-template-columns: 1fr; + } +} + +.admin-form-grid, +.user-row-form { + display: grid; + grid-template-columns: minmax(11rem, 1.2fr) minmax(14rem, 1.4fr) auto auto auto; + align-items: end; + gap: 0.55rem; +} + +.admin-form-grid input[type=text], +.admin-form-grid input[type=email], +.admin-form-grid input[type=password], +.admin-form-grid input[type=number], +.user-row-form input[type=text], +.user-row-form input[type=email], +.user-row-actions input[type=password] { + width: 100%; + box-sizing: border-box; +} + +.user-row-actions { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.7rem; + margin-top: 0.55rem; + padding-top: 0.55rem; + border-top: 1px solid #ddd; +} + +.user-row-actions form { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.45rem; +} + +.panel { + border: 1px solid #c8c8c8; + padding: 0.75rem; + background: #f8f8f8; +} + +h1, +h2, +h3 { + margin: 0.4rem 0 0.6rem 0; +} + +p { + margin: 0.4rem 0; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 0.5rem 0; +} + +th, +td { + border: 1px solid #ccc; + padding: 4px 6px; + vertical-align: top; +} + +th { + text-align: left; + background: #ececec; +} + +.token-valid td { + background: #edf8ed; + color: #176127; +} + +.token-expired td { + background: #fff0f0; + color: #9d1c1c; +} + +code, +pre, +textarea { + font-family: Consolas, "Courier New", monospace; +} + +textarea { + width: 100%; + box-sizing: border-box; +} + +input[type=text], +select { + max-width: 100%; +} + +button { + margin: 0.15rem 0.15rem 0.15rem 0; + padding: 0.32rem 0.65rem; + border: 1px solid #999; + border-radius: 4px; + background: #e9e9e9; + color: #242424; + cursor: pointer; +} + +button:hover, +button:focus { + background: #dcdcdc; + border-color: #777; +} + +.message { + padding: 0.5rem; + background: #eef8ee; + border: 1px solid #9c9; + margin: 0.5rem 0; +} + +.error { + padding: 0.5rem; + background: #fff0f0; + border: 1px solid #c99; + margin: 0.5rem 0; +} + +.small { + color: #555; + font-size: 12px; +} + +.actions form, +.version-actions form { + display: inline; +} + +.language-label { + font-weight: bold; + margin-right: 0.45rem; +} + +.language-switch a, +.language-switch .active-language { + display: inline-block; + margin: 0.15rem 0.35rem 0.15rem 0; + padding: 0.24rem 0.55rem; + border: 1px solid #aaa; + border-radius: 4px; + background: #ededed; + color: #252525; + text-decoration: none; +} + +.language-switch .active-language { + background: #555; + border-color: #555; + color: #fff; +} + +.hidden { + display: none; +} + +/* Prompt workbench */ + +.prompt-workbench { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + overflow: hidden; + align-items: stretch; +} + +.prompt-sidebar, +.prompt-viewer { + min-height: 0; + overflow: hidden; +} + +.prompt-sidebar { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.prompt-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem; +} + +.prompt-tab { + width: 100%; + min-height: 2.5rem; + margin: 0; +} + +.prompt-tab.active { + background: #555; + border-color: #555; + color: #fff; +} + +.prompt-tab[data-tab="defaults"].active { + border-color: #8a6a1f; +} + +.prompt-tab-panel { + flex: 1; + min-height: 0; + display: none; + flex-direction: column; + gap: 0.55rem; + overflow: hidden; +} + +.prompt-tab-panel.active { + display: flex; +} + +.sidebar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} + +.sidebar-actions button { + margin: 0; +} + +.default-admin-notice { + display: grid; + gap: 0.15rem; + padding: 0.55rem 0.65rem; + border: 1px solid #c7a949; + border-left: 4px solid #8a6a1f; + background: #fff8dc; + color: #3d3214; +} + +.default-admin-notice strong { + font-size: 13px; +} + +.default-admin-notice span { + font-size: 12px; +} + +.prompt-list { + flex: 1; + min-height: 0; + overflow: auto; + display: grid; + align-content: start; + gap: 0.45rem; + padding-right: 0.15rem; +} + +.prompt-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.35rem; +} + +.prompt-list-item form { + display: flex; + align-items: center; +} + +.prompt-list-item form button { + margin: 0; + white-space: nowrap; +} + +.prompt-select { + min-width: 0; + width: 100%; + margin: 0; + text-align: left; + background: #fdfdfd; +} + +.prompt-select.selected { + border-color: #555; + background: #ececec; + box-shadow: inset 3px 0 0 #555; +} + +.default-prompt-item .prompt-select { + border-left-color: #8a6a1f; + box-shadow: inset 3px 0 0 #c7a949; +} + +.default-prompt-item .prompt-select.selected { + border-color: #8a6a1f; + background: #fff8dc; + box-shadow: inset 4px 0 0 #8a6a1f; +} + +.prompt-name, +.prompt-subline { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.prompt-name { + font-weight: bold; +} + +.prompt-subline { + color: #555; + font-size: 12px; +} + +.create-drawer { + border-top: 1px solid #ddd; + padding-top: 0.55rem; +} + +.create-drawer > summary { + cursor: pointer; + font-weight: bold; +} + +.create-drawer form { + display: grid; + gap: 0.45rem; + margin-top: 0.55rem; +} + +.create-drawer input[type=text], +.create-drawer select, +.create-drawer textarea { + width: 100%; + box-sizing: border-box; +} + +.empty-state, +.viewer-empty { + color: #777; + font-style: italic; +} + +.prompt-viewer { + display: grid; +} + +.prompt-viewer.is-default-prompt { + border-color: #c7a949; +} + +.prompt-viewer.is-default-prompt .viewer-header { + border-bottom-color: #c7a949; +} + +.prompt-viewer.is-default-prompt .viewer-title::after { + content: " default"; + margin-left: 0.45rem; + padding: 0.08rem 0.35rem; + border: 1px solid #c7a949; + border-radius: 4px; + background: #fff8dc; + color: #5a4515; + font-size: 12px; + font-weight: normal; +} + +.prompt-viewer.is-empty .viewer-shell { + display: none; +} + +.prompt-viewer:not(.is-empty) .viewer-empty { + display: none; +} + +.viewer-shell { + min-height: 0; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; +} + +.viewer-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.35rem; + padding-bottom: 0.65rem; + border-bottom: 1px solid #ddd; +} + +.viewer-title { + font-weight: bold; + font-size: 1.15rem; +} + +.viewer-content { + min-height: 0; + overflow: auto; + margin: 0.7rem 0 0 0; + padding: 0.65rem; + border: 1px solid #ccc; + background: #fff; + white-space: pre-wrap; +} + +#promptModalForm:not(.can-edit) .modal-footer { + display: none; +} + +#promptModalForm.read-only-default .modal-title::after { + content: " view only"; + color: #777; + font-size: 12px; + font-weight: normal; +} + +/* Legacy modal classes, now reused by the inline prompt viewer. */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-backdrop.open { + display: flex; +} + +.prompt-modal { + width: min(96vw, 1500px); + height: min(94vh, 980px); + background: white; + border: 1px solid #888; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.modal-backdrop.is-default-prompt .prompt-modal { + border-color: #8a6a1f; +} + +.modal-backdrop.is-default-prompt .modal-header { + background: #fff8dc; +} + +.prompt-modal > form { + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; +} + +.modal-header, +.modal-toolbar, +.modal-footer { + padding: 0.6rem 0.8rem; + border-bottom: 1px solid #ddd; +} + +.modal-footer { + border-top: 1px solid #ddd; + border-bottom: none; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-title { + font-weight: bold; +} + +.modal-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.modal-body { + flex: 1; + min-height: 0; + display: flex; + gap: 0.6rem; + padding: 0.6rem; + box-sizing: border-box; + overflow: hidden; +} + +.edit-pane, +.version-pane { + flex: 1 1 0; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.content-label { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.edit-pane textarea { + display: block; + flex: 1; + height: 100%; + min-height: 0; + resize: none; +} + +.version-content { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid #ccc; + padding: 0.5rem; + background: #fafafa; + white-space: pre-wrap; + font-family: Consolas, "Courier New", monospace; +} + +.modal-form-grid { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + align-items: end; +} + +.modal-form-grid label { + display: block; +} + +.modal-form-grid input[type=text], +.modal-form-grid select, +.default-key-row input[type=text] { + width: 100%; + box-sizing: border-box; +} + +.default-key-row { + display: none; +} + +.diff-line { + display: block; + padding: 0 0.15rem; + min-height: 1.25em; +} + +.diff-same { + color: #000; +} + +.diff-added { + color: #064f16; + background: #e7f7e7; +} + +.diff-deleted { + color: #8a0000; + background: #ffe8e8; + text-decoration: line-through; +} + +.diff-changed { + color: #5c2380; + background: #f2e6ff; +} + +.diff-muted { + color: #999; +} + +.version-meta { + color: #555; + font-size: 12px; +} + +@media (max-width: 900px) { + .page-titlebar { + grid-template-columns: 1fr; + gap: 0.45rem; + } + + .header-nav { + justify-content: flex-start; + } + + .prompt-workbench { + grid-template-columns: 1fr; + grid-template-rows: minmax(220px, 38vh) minmax(0, 1fr); + } + + .modal-body { + flex-direction: column; + } + + .prompt-modal { + width: 98vw; + height: 96vh; + } +} + +.collapsible-panel > summary { + cursor: pointer; + font-size: 1.15rem; + font-weight: bold; + padding: 0.25rem 0; +} + +.collapsible-panel > summary .small { + font-weight: normal; + margin-left: 0.5rem; +} + +.prompt-preview > summary { + cursor: pointer; + color: #0645ad; +} + +.prompt-preview pre { + max-height: 14rem; + overflow: auto; + white-space: pre-wrap; + background: #fafafa; + border: 1px solid #ddd; + padding: 0.5rem; + margin: 0.4rem 0 0 0; +} diff --git a/users.php b/users.php new file mode 100644 index 0000000..99b93ea --- /dev/null +++ b/users.php @@ -0,0 +1,297 @@ +requireAdminHtml(); + +$message = ''; +$error = ''; + +function h($s) +{ + return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function t($key, $fallback = null) +{ + global $languageStore, $language; + + return $languageStore->translate($key, $language, $fallback); +} + +function post_value($name, $default = '') +{ + return $_POST[$name] ?? $default; +} + +function post_bool($name) +{ + return isset($_POST[$name]) && $_POST[$name] === '1'; +} + +function resolve_user_language($userSettings, $userId, $allowedLanguages) +{ + $language = isset($_GET['lang']) + ? (string)$_GET['lang'] + : (string)$userSettings->get($userId, 'language', 'en'); + + if (!in_array($language, $allowedLanguages, true)) { + $language = 'en'; + } + + $userSettings->set($userId, 'language', $language); + + return $language; +} + +function fmt_time($ts) +{ + if ($ts === null) { + return '-'; + } + + return date('Y-m-d H:i:s', (int)$ts); +} + +$language = resolve_user_language( + $userSettings, + $currentUser->id(), + $languageStore->supportedLanguages() +); + +$languageStore->seedDefaults(array( + 'app.title' => array('en' => 'Racket sandbox', 'nl' => 'Racket sandbox'), + 'app.manage_prompts' => array('en' => 'Manage prompts', 'nl' => 'Prompts beheren'), + 'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'), + 'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'), + 'app.language' => array('en' => 'Language', 'nl' => 'Taal'), + 'app.logged_in_as' => array('en' => 'Logged in as:', 'nl' => 'Ingelogd als:'), + 'app.admin' => array('en' => 'Admin', 'nl' => 'Admin'), + 'app.enabled' => array('en' => 'Enabled', 'nl' => 'Ingeschakeld'), + 'app.full_name' => array('en' => 'Full name', 'nl' => 'Volledige naam'), + 'app.email' => array('en' => 'Email', 'nl' => 'E-mail'), + 'app.password' => array('en' => 'Password', 'nl' => 'Wachtwoord'), + 'app.new_password' => array('en' => 'New password', 'nl' => 'Nieuw wachtwoord'), + 'app.created' => array('en' => 'Created', 'nl' => 'Gemaakt'), + 'app.last_login' => array('en' => 'Last login', 'nl' => 'Laatste login'), + 'app.actions' => array('en' => 'Actions', 'nl' => 'Acties'), + 'app.create_user' => array('en' => 'Create user', 'nl' => 'Gebruiker aanmaken'), + 'app.update_user' => array('en' => 'Update user', 'nl' => 'Gebruiker aanpassen'), + 'app.change_password' => array('en' => 'Change password', 'nl' => 'Wachtwoord wijzigen'), + 'app.delete_user' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'), + 'app.delete_user_confirm' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'), + 'app.cannot_delete_self' => array('en' => 'You cannot delete your own account.', 'nl' => 'Je kunt je eigen account niet verwijderen.'), + 'app.cannot_disable_self' => array('en' => 'You cannot disable your own account.', 'nl' => 'Je kunt je eigen account niet uitschakelen.'), + 'app.cannot_remove_own_admin' => array('en' => 'You cannot remove your own admin rights.', 'nl' => 'Je kunt je eigen adminrechten niet verwijderen.'), + 'app.user_created' => array('en' => 'User created.', 'nl' => 'Gebruiker aangemaakt.'), + 'app.user_updated' => array('en' => 'User updated.', 'nl' => 'Gebruiker aangepast.'), + 'app.password_changed' => array('en' => 'Password changed.', 'nl' => 'Wachtwoord gewijzigd.'), + 'app.user_deleted' => array('en' => 'User deleted.', 'nl' => 'Gebruiker verwijderd.'), + 'app.back_to_sandbox' => array('en' => 'Back to Racket sandbox', 'nl' => 'Terug naar Racket sandbox'), + 'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'), +)); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = post_value('action'); + + try { + if ($action === 'logout') { + $auth->logout(); + header('Location: /login.php'); + exit; + } elseif ($action === 'create_user') { + $auth->createUser( + post_value('email'), + post_value('full_name'), + post_value('password'), + post_bool('is_admin'), + post_bool('is_enabled') + ); + $message = t('app.user_created', 'User created.'); + } elseif ($action === 'update_user') { + $userId = (int)post_value('user_id'); + $isAdmin = post_bool('is_admin'); + $isEnabled = post_bool('is_enabled'); + + if ($userId === $currentUser->id() && !$isAdmin) { + throw new Exception(t('app.cannot_remove_own_admin', 'You cannot remove your own admin rights.')); + } + + if ($userId === $currentUser->id() && !$isEnabled) { + throw new Exception(t('app.cannot_disable_self', 'You cannot disable your own account.')); + } + + $auth->updateUser($userId, post_value('email'), post_value('full_name')); + $auth->setAdmin($userId, $isAdmin); + $auth->setEnabled($userId, $isEnabled); + $message = t('app.user_updated', 'User updated.'); + } elseif ($action === 'set_password') { + $auth->setPassword(post_value('email'), post_value('password')); + $message = t('app.password_changed', 'Password changed.'); + } elseif ($action === 'delete_user') { + $userId = (int)post_value('user_id'); + + if ($userId === $currentUser->id()) { + throw new Exception(t('app.cannot_delete_self', 'You cannot delete your own account.')); + } + + $auth->deleteUser($userId); + $message = t('app.user_deleted', 'User deleted.'); + } + } catch (Throwable $e) { + $error = $e->getMessage(); + } +} + +$users = $auth->listUsers(); +$headerLanguages = array(); + +foreach ($languageStore->supportedLanguages() as $lang) { + $headerLanguages[$lang] = $languageStore->languageLabel($lang); +} + +header('Content-Type: text/html; charset=utf-8'); +?> + + + + +<?= h(t('app.user_management', 'User management')) ?> + + + + +
+ + t('app.user_management', 'User management'), + 'nav_items' => array( + array('label' => t('app.back_to_sandbox', 'Back to Racket sandbox'), 'url' => '/?lang=' . rawurlencode($language)), + array( + 'label' => t('app.manage_prompts', 'Manage prompts'), + 'url' => '/prompts?lang=' . rawurlencode($language), + 'separator_before' => true, + ), + array( + 'label' => t('app.user_management', 'User management'), + 'url' => '/users?lang=' . rawurlencode($language), + 'active' => true, + 'separator_before' => true, + ), + array( + 'label' => t('app.configuration', 'Configuration'), + 'url' => '/admin-config?lang=' . rawurlencode($language), + 'separator_before' => true, + ), + ), + 'user' => $currentUser, + 'user_prefix' => t('app.logged_in_as', 'Logged in as:'), + 'admin_label' => t('app.admin', 'Admin'), + 'language_label' => t('app.language', 'Language'), + 'language' => $language, + 'languages' => $headerLanguages, + 'language_action' => '/users', + 'logout_action' => '/users?lang=' . rawurlencode($language), + 'logout_label' => t('app.logout', 'Logout'), + 'message' => $message, + 'error' => $error, +)); +?> + +
+ +
+

+ +
+ + + + + + + +
+
+ +
+

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +createdAt())) ?> +lastLoginAt())) ?> + +
+ +
+
+ + + + +
+ +id() !== $currentUser->id()): ?> +
+ + + +
+ +

+ +
+
+
+ +
+
+ + diff --git a/usersettings.php b/usersettings.php new file mode 100644 index 0000000..2db85dc --- /dev/null +++ b/usersettings.php @@ -0,0 +1,111 @@ +db = new PDO('sqlite:' . $dbFile); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->db->exec('PRAGMA journal_mode = WAL'); + $this->db->exec('PRAGMA busy_timeout = 5000'); + + $this->init(); + } + + private function init() + { + $this->db->exec( + 'CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER NOT NULL, + setting_key TEXT NOT NULL, + setting_value TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(user_id, setting_key) + )' + ); + } + + public function get($userId, $key, $default = null) + { + $stmt = $this->db->prepare( + 'SELECT setting_value + FROM user_settings + WHERE user_id = :user_id + AND setting_key = :setting_key' + ); + $stmt->execute(array( + ':user_id' => $this->safeUserId($userId), + ':setting_key' => $this->safeKey($key), + )); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? (string)$row['setting_value'] : $default; + } + + public function set($userId, $key, $value) + { + $now = time(); + + $stmt = $this->db->prepare( + 'INSERT INTO user_settings + (user_id, setting_key, setting_value, created_at, updated_at) + VALUES + (:user_id, :setting_key, :setting_value, :created_at, :updated_at) + ON CONFLICT(user_id, setting_key) + DO UPDATE SET + setting_value = excluded.setting_value, + updated_at = excluded.updated_at' + ); + + $stmt->execute(array( + ':user_id' => $this->safeUserId($userId), + ':setting_key' => $this->safeKey($key), + ':setting_value' => (string)$value, + ':created_at' => $now, + ':updated_at' => $now, + )); + } + + private function safeUserId($userId) + { + $userId = (int)$userId; + + if ($userId <= 0) { + throw new UserSettingsStoreException('Invalid user id.'); + } + + return $userId; + } + + private function safeKey($key) + { + $key = trim((string)$key); + + if (!preg_match('/^[a-z0-9_.-]{1,80}$/', $key)) { + throw new UserSettingsStoreException('Invalid setting key.'); + } + + return $key; + } +}