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,
+));
+?>
+
+
+
+
+= h(t('app.download_settings', 'Download settings')) ?>
+
+
+
+= h(t('app.chunk_size_hint_v2', '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.')) ?>
+
+
+
+= h(t('app.racket_zip_chunk_kb', 'Racket installation max base64 chunk size (KiB)')) ?>
+= h($base64ChunkConfig['racket_zip_max_base64_kb']) ?> KiB
+= h(t('app.effective_binary_chunk', 'Effective binary chunk')) ?>: = h(base64_binary_chunk_bytes_for_kib($base64ChunkConfig['racket_zip_max_base64_kb'])) ?> bytes
+
+
+= h(t('app.package_zip_chunk_kb', 'Package/module max base64 chunk size (KiB)')) ?>
+= h($base64ChunkConfig['package_zip_max_base64_kb']) ?> KiB
+= h(t('app.effective_binary_chunk', 'Effective binary chunk')) ?>: = h(base64_binary_chunk_bytes_for_kib($base64ChunkConfig['package_zip_max_base64_kb'])) ?> bytes
+
+
+= h(t('app.racket_zip_source', 'Racket installation source')) ?>
+config/racket.zip
+= h(is_file(RACKET_ZIP_FILE) ? (string)filesize(RACKET_ZIP_FILE) : '0') ?> bytes
+
+
+= h(t('app.racket_parts', 'Racket installation parts')) ?>
+= h((string)($racketPartsManifest['part_count'] ?? 0)) ?>
+
+
+= h(t('app.racket_parts_current', 'current')) ?>,
+= h((string)($racketPartsManifest['created_at'] ?? '')) ?>
+
+= h(t('app.racket_parts_missing', 'missing or outdated; save configuration to regenerate')) ?>
+
+
+
+
+
+
+
+= h(t('app.maintenance', 'Maintenance')) ?>
+
+
+= h(t('app.next_tokens', 'Next tokens')) ?>
+
+
+
+
+= h(t('app.cleanup_help', 'Expired links should return an outdated information message to the AI agent. Cleanup only removes old token rows from SQLite.')) ?>
+
+
+= h(t('app.current_next_tokens', 'Current next tokens')) ?>
+
+ 0): ?>
+
+
+= h(t('app.token', 'Token')) ?>
+= h(t('app.created_at', 'Created at')) ?>
+= h(t('app.expires_at', 'Expires at')) ?>
+
+
+
+= h($nextToken['token'] ?? '') ?>
+= h(format_token_time($nextToken['created_at'] ?? 0)) ?>
+= h(format_token_time($nextToken['expires_at'] ?? 0)) ?>
+
+
+
+
+= h(t('app.no_current_next_tokens', 'No current next tokens.')) ?>
+
+
+
+
+
+
+
+
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,
+));
+?>
+
+
+
+
+= h(t('app.bootstrap_link', 'Bootstrap link')) ?>
+
+
+= h(t('app.generate_bootstrap_link', 'Generate bootstrap link')) ?>
+
+
+
+
+
+
+
= h(t('app.generated_link', 'Generated link')) ?>
+
+
+
+
+
+
+
= h(t('app.copy_full_prompt', 'Copy full prompt')) ?>
+
+= h(t('app.bootstrap_prompt_help', 'Choose one of your prompts. The placeholder {{bootstrap-racket-link}} is replaced by the generated bootstrap link.')) ?>
+
+
+ 0): ?>
+
+
+= h(t('app.select_prompt', 'Select prompt')) ?>
+
+
+= h($prompt['name']) ?>
+
+
+
+
+
+
+
+= h(t('app.copy_full_prompt', 'Copy full prompt')) ?>
+
+
+
+
+
= h(t('app.no_bootstrap_prompts', 'No personal prompts are available for this language. Copy a default prompt first from prompt management.')) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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) ?>
+
+
+
+
+
+
+= h($pageTitle) ?>
+
+
+= h($error) ?>
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+part
+base64 bytes
+text/plain URL
+
+
+
+' . $rows . '
+
+
+
+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,
+));
+?>
+
+
+
+
+
+
+
+= h(t('prompts.select_prompt', 'Select a prompt on the left to view it.')) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+#
+partnummer
+bytes
+base64 text/plain URL
+
+
+
+' . $rows . '
+
+
+
+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) . '
+
+
+
+
+
+#
+package
+
+
+
+' . $rows . '
+
+
+
+
+');
+}
+
+$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,
+));
+?>
+
+
+
+
+
+
+= h(t('app.user_management', 'User management')) ?>
+
+
+
+
+= h(t('app.full_name', 'Full name')) ?>
+= h(t('app.email', 'Email')) ?>
+= h(t('app.admin', 'Admin')) ?>
+= h(t('app.enabled', 'Enabled')) ?>
+= h(t('app.created', 'Created')) ?>
+= h(t('app.last_login', 'Last login')) ?>
+= h(t('app.actions', 'Actions')) ?>
+
+
+
+
+
+
+
+
+
+
+= h(t('app.full_name', 'Full name')) ?>
+= h(t('app.email', 'Email')) ?>
+ isAdmin() ? 'checked' : '' ?>> = h(t('app.admin', 'Admin')) ?>
+ isEnabled() ? 'checked' : '' ?>> = h(t('app.enabled', 'Enabled')) ?>
+= h(fmt_time($managedUser->createdAt())) ?>
+= h(fmt_time($managedUser->lastLoginAt())) ?>
+= h(t('app.update_user', 'Update user')) ?>
+
+
+
+
+
+
+= h(t('app.new_password', 'New password')) ?>
+= h(t('app.change_password', 'Change password')) ?>
+
+
+id() !== $currentUser->id()): ?>
+
+
+
+= h(t('app.delete_user', 'Delete user')) ?>
+
+
+
= h(t('app.cannot_delete_self', 'You cannot delete your own account.')) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
+ }
+}