initial import

This commit is contained in:
www-data
2026-05-25 13:47:46 +02:00
parent 3e7f238cf4
commit 97f23260ed
32 changed files with 8898 additions and 0 deletions
+4
View File
@@ -42,3 +42,7 @@ compiled/
/app/Config/database.php /app/Config/database.php
/vendors/* /vendors/*
.rktsndbx-cache
data
pkg-cache
+17
View File
@@ -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]
+722
View File
@@ -0,0 +1,722 @@
<?php
/*
* auth.php
*
* Encapsulated authentication/user module for the Racket sandbox.
*
* Database:
* data/racket-sandbox.sqlite
*
* Public model:
* RacketSandboxAuth
* RacketSandboxUser
*
* Outside code should not inspect DB rows directly.
*/
class RacketSandboxAuthException extends Exception
{
}
class RacketSandboxUser
{
private $row;
public function __construct($row)
{
$this->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.',
'<p><a href="/login.php">Login</a></p>'
);
}
private function messageHtml($title, $message, $extra = '')
{
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</title>
</head>
<body>
<h1>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>
<p>' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>
' . $extra . '
</body>
</html>';
exit;
}
}
+264
View File
@@ -0,0 +1,264 @@
<?php
/*
* b64parts.php
*
* Zet een gegeven bestand, bijvoorbeeld een zip, om naar base64-parts.
*
* Regels:
* - Inputbestand mag binary zijn.
* - Output-parts zijn plain base64 tekst.
* - Geen chunk_split; dus geen extra newline-overhead.
* - Max part size geldt voor de base64-tekst, niet voor de binary input.
*
* Voorbeeld:
*
* require_once __DIR__ . '/b64parts.php';
*
* $parts = new Base64Parts(__DIR__ . '/data');
* $manifest = $parts->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';
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/*
* Shared configuration for maximum base64 payload sizes.
*/
define('BASE64_CHUNK_CONFIG_FILE', __DIR__ . '/config/base64-chunks.php');
function base64_chunk_defaults()
{
return array(
'racket_zip_max_base64_kb' => 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 = "<?php\n" .
"/*\n" .
" * Maximale grootte van base64-payloads, in KiB.\n" .
" *\n" .
" * 1 KiB = 1024 bytes. De splitter rondt intern waar nodig naar beneden af\n" .
" * zodat de binaire chunks netjes naar base64 omgezet kunnen worden.\n" .
" */\n\n" .
"return " . var_export($clean, true) . ";\n";
$tmp = BASE64_CHUNK_CONFIG_FILE . '.tmp';
if (file_put_contents($tmp, $php, LOCK_EX) === false) {
throw new RuntimeException('Could not write temporary base64 chunk config.');
}
if (!rename($tmp, BASE64_CHUNK_CONFIG_FILE)) {
@unlink($tmp);
throw new RuntimeException('Could not replace base64 chunk config.');
}
}
+45
View File
@@ -0,0 +1,45 @@
(function () {
'use strict';
function byId(id) {
return document.getElementById(id);
}
function renderPrompt(data, select, output) {
const selectedId = select.value;
const prompt = (data.prompts || []).find(function (item) {
return String(item.id) === String(selectedId);
});
if (!prompt) {
output.value = '';
return;
}
output.value = String(prompt.content || '').split('{{bootstrap-racket-link}}').join(data.link || '');
}
document.addEventListener('DOMContentLoaded', function () {
const dataEl = byId('bootstrapPromptData');
const select = byId('bootstrapPromptSelect');
const output = byId('bootstrapPromptOutput');
if (!dataEl || !select || !output) {
return;
}
let data = { link: '', prompts: [] };
try {
data = JSON.parse(dataEl.textContent || '{}');
} catch (e) {
data = { link: '', prompts: [] };
}
select.addEventListener('change', function () {
renderPrompt(data, select, output);
});
renderPrompt(data, select, output);
});
}());
+56
View File
@@ -0,0 +1,56 @@
(function () {
'use strict';
function fallbackCopy(text) {
const input = document.createElement('textarea');
input.value = text;
input.setAttribute('readonly', 'readonly');
input.style.position = 'fixed';
input.style.left = '-9999px';
document.body.appendChild(input);
input.select();
try {
document.execCommand('copy');
} finally {
document.body.removeChild(input);
}
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
fallbackCopy(text);
return Promise.resolve();
}
function showCopied(button) {
const original = button.dataset.copyLabel || button.textContent;
const copied = button.dataset.copiedLabel || 'Copied';
button.textContent = copied;
window.setTimeout(function () {
button.textContent = original;
}, 1400);
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.js-copy-button').forEach(function (button) {
button.addEventListener('click', function () {
let text = button.dataset.copyText || '';
const targetId = button.dataset.copyTarget || '';
const target = targetId ? document.getElementById(targetId) : null;
if (target) {
text = target.value || target.textContent || '';
}
copyText(text).then(function () {
showCopied(button);
});
});
});
});
}());
+349
View File
@@ -0,0 +1,349 @@
<?php
/*
* config.php
*
* Admin application configuration.
*/
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/header.php';
require_once __DIR__ . '/languagestore.php';
require_once __DIR__ . '/nexttoken.php';
require_once __DIR__ . '/usersettings.php';
require_once __DIR__ . '/base64config.php';
require_once __DIR__ . '/racketzip.php';
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
error_reporting(E_ALL);
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
$auth = new RacketSandboxAuth($DB_FILE);
$languageStore = new LanguageStore($DB_FILE);
$tokens = new NextTokenStore($DB_FILE);
$userSettings = new UserSettingsStore($DB_FILE);
$currentUser = $auth->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');
?>
<!doctype html>
<html lang="<?= h($language) ?>">
<head>
<meta charset="utf-8">
<title><?= h(t('app.configuration', 'Configuration')) ?></title>
<link rel="stylesheet" href="/styles.css?v=<?= h($styleVersion) ?>">
</head>
<body>
<div class="page">
<?php
render_app_header(array(
'title' => 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,
));
?>
<main class="page-main dashboard-main">
<section class="panel">
<h2><?= h(t('app.download_settings', 'Download settings')) ?></h2>
<form method="post" action="/admin-config?lang=<?= h($language) ?>" class="admin-form-grid">
<input type="hidden" name="action" value="update_config">
<label>
<?= h(t('app.racket_zip_chunk_kb', 'Racket installation max base64 chunk size (KiB)')) ?><br>
<input type="number" name="racket_zip_max_base64_kb" min="1" step="1" value="<?= h($base64ChunkConfig['racket_zip_max_base64_kb']) ?>" required>
</label>
<label>
<?= h(t('app.package_zip_chunk_kb', 'Package/module max base64 chunk size (KiB)')) ?><br>
<input type="number" name="package_zip_max_base64_kb" min="1" step="1" value="<?= h($base64ChunkConfig['package_zip_max_base64_kb']) ?>" required>
</label>
<button type="submit"><?= h(t('app.save_configuration', 'Save configuration')) ?></button>
</form>
<p class="small"><?= 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.')) ?></p>
<table>
<tr>
<th><?= h(t('app.racket_zip_chunk_kb', 'Racket installation max base64 chunk size (KiB)')) ?></th>
<td><code><?= h($base64ChunkConfig['racket_zip_max_base64_kb']) ?></code> KiB</td>
<td><?= h(t('app.effective_binary_chunk', 'Effective binary chunk')) ?>: <code><?= h(base64_binary_chunk_bytes_for_kib($base64ChunkConfig['racket_zip_max_base64_kb'])) ?></code> bytes</td>
</tr>
<tr>
<th><?= h(t('app.package_zip_chunk_kb', 'Package/module max base64 chunk size (KiB)')) ?></th>
<td><code><?= h($base64ChunkConfig['package_zip_max_base64_kb']) ?></code> KiB</td>
<td><?= h(t('app.effective_binary_chunk', 'Effective binary chunk')) ?>: <code><?= h(base64_binary_chunk_bytes_for_kib($base64ChunkConfig['package_zip_max_base64_kb'])) ?></code> bytes</td>
</tr>
<tr>
<th><?= h(t('app.racket_zip_source', 'Racket installation source')) ?></th>
<td><code>config/racket.zip</code></td>
<td><code><?= h(is_file(RACKET_ZIP_FILE) ? (string)filesize(RACKET_ZIP_FILE) : '0') ?></code> bytes</td>
</tr>
<tr>
<th><?= h(t('app.racket_parts', 'Racket installation parts')) ?></th>
<td><code><?= h((string)($racketPartsManifest['part_count'] ?? 0)) ?></code></td>
<td>
<?php if ($racketPartsCurrent): ?>
<?= h(t('app.racket_parts_current', 'current')) ?>,
<?= h((string)($racketPartsManifest['created_at'] ?? '')) ?>
<?php else: ?>
<?= h(t('app.racket_parts_missing', 'missing or outdated; save configuration to regenerate')) ?>
<?php endif; ?>
</td>
</tr>
</table>
</section>
<section class="panel">
<h2><?= h(t('app.maintenance', 'Maintenance')) ?></h2>
<fieldset>
<legend><?= h(t('app.next_tokens', 'Next tokens')) ?></legend>
<form method="post" action="/admin-config?lang=<?= h($language) ?>">
<input type="hidden" name="action" value="cleanup_tokens">
<button type="submit"><?= h(t('app.remove_expired_tokens', 'Remove expired next tokens')) ?></button>
</form>
<p class="small">
<?= 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.')) ?>
</p>
<h3><?= h(t('app.current_next_tokens', 'Current next tokens')) ?></h3>
<?php if (count($currentNextTokens) > 0): ?>
<table>
<tr>
<th><?= h(t('app.token', 'Token')) ?></th>
<th><?= h(t('app.created_at', 'Created at')) ?></th>
<th><?= h(t('app.expires_at', 'Expires at')) ?></th>
</tr>
<?php foreach ($currentNextTokens as $nextToken): ?>
<tr class="<?= h(token_status_class($nextToken)) ?>">
<td><code><?= h($nextToken['token'] ?? '') ?></code></td>
<td><?= h(format_token_time($nextToken['created_at'] ?? 0)) ?></td>
<td><?= h(format_token_time($nextToken['expires_at'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php else: ?>
<p class="small"><?= h(t('app.no_current_next_tokens', 'No current next tokens.')) ?></p>
<?php endif; ?>
</fieldset>
</section>
</main>
</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
flfadrdeyc.yvtpmoyjm.gthfkqbrf.kyhhvikcv
+12
View File
@@ -0,0 +1,12 @@
<?php
/*
* Maximale grootte van base64-payloads, in KiB.
*
* 1 KiB = 1024 bytes. De splitter rondt intern waar nodig naar beneden af
* zodat de binaire chunks netjes naar base64 omgezet kunnen worden.
*/
return array (
'racket_zip_max_base64_kb' => 10240,
'package_zip_max_base64_kb' => 2048,
);
+2
View File
@@ -0,0 +1,2 @@
<?php
require __DIR__ . '/../config.php';
+93
View File
@@ -0,0 +1,93 @@
<?php
function expand_next_token_words($baseWords, $modifiers, $targetCount)
{
$out = array();
foreach ($baseWords as $word) {
$out[$word] = true;
}
foreach ($modifiers as $modifier) {
foreach ($baseWords as $word) {
$out[$modifier . '-' . $word] = true;
if (count($out) >= $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),
),
);
BIN
View File
Binary file not shown.
+578
View File
@@ -0,0 +1,578 @@
<?php
/*
* gitfetcher.php
*
* Doel:
* Voor een gegeven package-naam en git repository:
*
* 1. bepaal de default branch;
* 2. bepaal de huidige HEAD commit SHA;
* 3. controleer data/<package>.json;
* 4. als de SHA gelijk is en data/<package>.zip bestaat: niets downloaden;
* 5. anders: haal de repository-zip op en sla op als data/<package>.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/<ref>.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;
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
/*
* header.php
*
* Shared page header renderer for logged-in application pages.
*/
function app_header_h($s)
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function render_app_header($options)
{
$title = (string)($options['title'] ?? '');
$navItems = $options['nav_items'] ?? array();
$user = $options['user'] ?? null;
$userPrefix = (string)($options['user_prefix'] ?? '');
$adminLabel = (string)($options['admin_label'] ?? 'admin');
$languageLabel = (string)($options['language_label'] ?? 'Language');
$language = (string)($options['language'] ?? 'en');
$languages = $options['languages'] ?? array();
$languageAction = (string)($options['language_action'] ?? '/');
$languageHidden = $options['language_hidden'] ?? array();
$logoutAction = (string)($options['logout_action'] ?? '');
$logoutLabel = (string)($options['logout_label'] ?? '');
$message = (string)($options['message'] ?? '');
$error = (string)($options['error'] ?? '');
?>
<header class="page-header">
<div class="page-titlebar">
<h1><?= app_header_h($title) ?></h1>
<nav class="header-nav" aria-label="<?= app_header_h($title) ?> navigation">
<?php foreach ($navItems as $item): ?>
<?php if (!empty($item['separator_before'])): ?>
<span class="nav-separator" aria-hidden="true">|</span>
<?php endif; ?>
<?php if (!empty($item['active'])): ?>
<strong><?= app_header_h($item['label'] ?? '') ?></strong>
<?php else: ?>
<a href="<?= app_header_h($item['url'] ?? '#') ?>"><?= app_header_h($item['label'] ?? '') ?></a>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($logoutAction !== '' && $logoutLabel !== ''): ?>
<span class="nav-separator" aria-hidden="true">|</span>
<form class="header-action-form" method="post" action="<?= app_header_h($logoutAction) ?>">
<input type="hidden" name="action" value="logout">
<button type="submit"><?= app_header_h($logoutLabel) ?></button>
</form>
<?php endif; ?>
<?php if ($user !== null): ?>
<span class="nav-separator" aria-hidden="true">|</span>
<span class="nav-user">
<?php if ($userPrefix !== ''): ?>
<?= app_header_h($userPrefix) ?>
<?php endif; ?>
<?= app_header_h($user->displayName()) ?>
<?php if ($user->isAdmin()): ?>
<span class="small">(<?= app_header_h($adminLabel) ?>)</span>
<?php endif; ?>
</span>
<?php endif; ?>
<form class="header-language-form" method="get" action="<?= app_header_h($languageAction) ?>">
<?php foreach ($languageHidden as $name => $value): ?>
<input type="hidden" name="<?= app_header_h($name) ?>" value="<?= app_header_h($value) ?>">
<?php endforeach; ?>
<label>
<?= app_header_h($languageLabel) ?>
<select name="lang" onchange="this.form.submit()">
<?php foreach ($languages as $lang => $label): ?>
<option value="<?= app_header_h($lang) ?>" <?= $lang === $language ? 'selected' : '' ?>>
<?= app_header_h($label) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</form>
</nav>
</div>
<?php if ($message !== ''): ?>
<div class="message"><?= app_header_h($message) ?></div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="error"><?= app_header_h($error) ?></div>
<?php endif; ?>
</header>
<?php
}
+470
View File
@@ -0,0 +1,470 @@
<?php
/*
* index.php
*
* Menselijk startpunt voor Racket sandbox.
*
* Niet ingelogd:
* - verwijst naar login
*
* Ingelogd:
* - bootstrap-link genereren
* - logout
*
* Admin:
* - gebruikersoverzicht
* - admin/enabled vlaggen wijzigen
* - wachtwoord wijzigen
* - gebruiker verwijderen
*/
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/header.php';
require_once __DIR__ . '/languagestore.php';
require_once __DIR__ . '/nexttoken.php';
require_once __DIR__ . '/promptstore.php';
require_once __DIR__ . '/usersettings.php';
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
error_reporting(E_ALL);
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
$auth = new RacketSandboxAuth($DB_FILE);
$languageStore = new LanguageStore($DB_FILE);
$tokens = new NextTokenStore($DB_FILE);
$promptStore = new PromptStore($DB_FILE);
$userSettings = new UserSettingsStore($DB_FILE);
$message = '';
$error = '';
$issuedLink = '';
$bootstrapPrompts = array();
define('BOOTSTRAP_TTL_DEFAULT_MINUTES', 120);
define('BOOTSTRAP_TTL_MIN_MINUTES', 30);
define('BOOTSTRAP_TTL_MAX_MINUTES', 8 * 60);
function h($s)
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function get_value($name, $default = '')
{
return $_GET[$name] ?? $default;
}
function t($key, $fallback = null)
{
global $languageStore, $language;
return $languageStore->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');
?>
<!doctype html>
<html lang="<?= h($language) ?>">
<head>
<meta charset="utf-8">
<title><?= h(t('app.title', 'Racket sandbox')) ?></title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="page">
<?php
render_app_header(array(
'title' => 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,
));
?>
<main class="page-main dashboard-main">
<section class="panel">
<h2><?= h(t('app.bootstrap_link', 'Bootstrap link')) ?></h2>
<fieldset>
<legend><?= h(t('app.generate_bootstrap_link', 'Generate bootstrap link')) ?></legend>
<div class="bootstrap-result-grid <?= $issuedLink !== '' ? 'has-prompt' : '' ?>">
<div>
<form method="post" action="/?lang=<?= h($language) ?>">
<input type="hidden" name="action" value="issue_bootstrap">
<label>
<?= h(t('app.ttl_minutes', 'TTL in minutes')) ?><br>
<input type="number"
name="ttl_minutes"
value="<?= h($bootstrapTtlMinutes) ?>"
min="<?= h(BOOTSTRAP_TTL_MIN_MINUTES) ?>"
max="<?= h(BOOTSTRAP_TTL_MAX_MINUTES) ?>">
</label>
<p class="small"><?= h(t('app.ttl_range_help', 'Allowed range: 30 minutes to 8 hours.')) ?></p>
<button type="submit"><?= h(t('app.generate_bootstrap_link', 'Generate bootstrap link')) ?></button>
</form>
<?php if ($issuedLink !== ''): ?>
<h3><?= h(t('app.generated_link', 'Generated link')) ?></h3>
<div class="generated-link-row">
<a href="<?= h($issuedLink) ?>" target="_blank" rel="noopener noreferrer"><?= h($issuedLink) ?></a>
<button type="button"
class="js-copy-button"
data-copy-text="<?= h($issuedLink) ?>"
data-copy-label="<?= h(t('app.copy', 'Copy')) ?>"
data-copied-label="<?= h(t('app.copied', 'Copied')) ?>">
<?= h(t('app.copy', 'Copy')) ?>
</button>
</div>
<?php endif; ?>
</div>
<?php if ($issuedLink !== ''): ?>
<div>
<h3><?= h(t('app.copy_full_prompt', 'Copy full prompt')) ?></h3>
<p>
<?= h(t('app.bootstrap_prompt_help', 'Choose one of your prompts. The placeholder {{bootstrap-racket-link}} is replaced by the generated bootstrap link.')) ?>
</p>
<?php if (count($bootstrapPrompts) > 0): ?>
<div class="bootstrap-prompt-tool">
<label>
<?= h(t('app.select_prompt', 'Select prompt')) ?><br>
<select id="bootstrapPromptSelect">
<?php foreach ($bootstrapPrompts as $prompt): ?>
<option value="<?= h($prompt['id']) ?>"><?= h($prompt['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<textarea id="bootstrapPromptOutput" readonly rows="12"></textarea>
<button type="button"
class="js-copy-button"
data-copy-target="bootstrapPromptOutput"
data-copy-label="<?= h(t('app.copy', 'Copy')) ?>"
data-copied-label="<?= h(t('app.copied', 'Copied')) ?>">
<?= h(t('app.copy_full_prompt', 'Copy full prompt')) ?>
</button>
</div>
<script type="application/json" id="bootstrapPromptData">
<?= json_encode(array(
'link' => $issuedLink,
'prompts' => $bootstrapPrompts,
), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
</script>
<?php else: ?>
<p class="small"><?= h(t('app.no_bootstrap_prompts', 'No personal prompts are available for this language. Copy a default prompt first from prompt management.')) ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</fieldset>
</section>
</main>
</div>
<script src="/clipboard.js" defer></script>
<script src="/bootstrap-prompt.js" defer></script>
</body>
</html>
+205
View File
@@ -0,0 +1,205 @@
<?php
/*
* languagestore.php
*
* Small SQLite-backed translation module.
*
* Database:
* data/racket-sandbox.sqlite
*/
class LanguageStoreException extends Exception
{
}
class LanguageStore
{
private $db;
private $fallbackLanguage;
public function __construct($dbFile, $fallbackLanguage = 'en')
{
$dir = dirname($dbFile);
if (!is_dir($dir)) {
if (!mkdir($dir, 0700, true)) {
throw new LanguageStoreException('Could not create database directory: ' . $dir);
}
}
$this->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;
}
}
+228
View File
@@ -0,0 +1,228 @@
<?php
function catalog_http_normalize_header_name($name)
{
return strtolower(trim($name));
}
function catalog_http_ensure_dir($dir)
{
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true)) {
throw new RuntimeException('Kan cache directory niet maken: ' . $dir);
}
}
if (!is_writable($dir)) {
throw new RuntimeException('Cache directory is niet schrijfbaar: ' . $dir);
}
}
function catalog_http_read_meta($metaFile)
{
if ($metaFile === null || !is_file($metaFile)) {
return array();
}
$json = file_get_contents($metaFile);
if ($json === false || $json === '') {
return array();
}
$meta = json_decode($json, true);
return is_array($meta) ? $meta : array();
}
function catalog_http_write_meta($metaFile, $meta)
{
if ($metaFile === null) {
return;
}
$json = json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Kan catalogus-cache metadata niet coderen.');
}
if (file_put_contents($metaFile, $json . "\n", LOCK_EX) === false) {
throw new RuntimeException('Kan catalogus-cache metadata niet schrijven: ' . $metaFile);
}
}
function catalog_http_validator_headers($meta)
{
$headers = array();
if (!empty($meta['etag'])) {
$headers[] = 'If-None-Match: ' . $meta['etag'];
}
if (!empty($meta['last_modified'])) {
$headers[] = 'If-Modified-Since: ' . $meta['last_modified'];
}
return $headers;
}
function catalog_http_fetch($url, $requestHeaders = array(), $userAgent = 'rktsndbx-catalog/1.0', $timeout = 180)
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
$responseHeaders = array();
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER => 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;
}
+317
View File
@@ -0,0 +1,317 @@
<?php
function rktd_package_name_ok($name)
{
return is_string($name) && preg_match('/^[A-Za-z0-9_.+-]+$/', $name);
}
function rktd_unescape_string($s)
{
$out = '';
$n = strlen($s);
for ($i = 0; $i < $n; $i++) {
$c = $s[$i];
if ($c !== '\\') {
$out .= $c;
continue;
}
if ($i + 1 >= $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;
}
+143
View File
@@ -0,0 +1,143 @@
<?php
/*
* login.php
*/
require_once __DIR__ . '/auth.php';
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
error_reporting(E_ALL);
$auth = new RacketSandboxAuth(__DIR__ . '/data/racket-sandbox.sqlite');
$error = '';
function h($s)
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function detect_login_language($supported, $fallback)
{
$header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
$preferences = array();
foreach (explode(',', $header) as $part) {
$pieces = array_map('trim', explode(';', $part));
$lang = strtolower($pieces[0] ?? '');
$quality = 1.0;
foreach (array_slice($pieces, 1) as $piece) {
if (strpos($piece, 'q=') === 0) {
$quality = (float)substr($piece, 2);
}
}
if ($lang !== '') {
$preferences[] = array('lang' => $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');
?>
<!doctype html>
<html lang="<?= h($language) ?>">
<head>
<meta charset="utf-8">
<title><?= h($pageTitle) ?></title>
<link rel="stylesheet" href="/styles.css?v=<?= h($styleVersion) ?>">
</head>
<body class="simple-doc login-page">
<main class="login-layout">
<section class="login-panel">
<h1><?= h($pageTitle) ?></h1>
<?php if ($error !== ''): ?>
<div class="error"><?= h($error) ?></div>
<?php endif; ?>
<form method="post" action="/login.php">
<label>
<?= h($texts[$language]['email']) ?><br>
<input type="email" name="email" autocomplete="username" required>
</label>
<label>
<?= h($texts[$language]['password']) ?><br>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit"><?= h($texts[$language]['login']) ?></button>
</form>
</section>
<aside class="login-request-panel">
<h2><?= h($texts[$language]['account_title']) ?></h2>
<p><?= h($texts[$language]['account_text']) ?></p>
<p><a href="https://racket.discourse.group/"><?= h($texts[$language]['account_link']) ?></a></p>
</aside>
</main>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<?php
/*
* make-user.php
*
* CLI tool om handmatig gebruikers aan te maken.
*
* Gebruik:
*
* php make-user.php <email> "<full name>" <password> <admin:0|1>
*
* 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 <email> \"<full name>\" <password> <admin:0|1>\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);
}
+355
View File
@@ -0,0 +1,355 @@
<?php
/*
* nexttoken.php
*
* Eenvoudige SQLite-backed next-token store.
*
* Model:
* - Iedere gegenereerde HTML-pagina maakt één next-token.
* - Alle links op die pagina gebruiken datzelfde token.
* - Vervolgroutes accepteren een request alleen als next bestaat
* en nog niet verlopen is.
* - Het token heeft verder geen betekenis.
*/
class NextTokenException extends Exception
{
}
class NextTokenStore
{
private $db;
private $ttlSeconds;
public function __construct($dbFile, $ttlSeconds = 21600)
{
$this->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('<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Expired link</title>
</head>
<body>
<h1>Expired or unauthorized link</h1>
<p>
This link is no longer valid. Please ask the human user for a new
bootstrap link and restart the process from that link.
</p>
<p>
For the AI agent: do not try to invent a new <code>next</code> value.
Only follow links that appear on authorized HTML pages.
</p>
</body>
</html>', 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,
));
}
}
}
}
}
+476
View File
@@ -0,0 +1,476 @@
<?php
/*
* package.php
*
* Vereist:
*
* gitfetcher.php
* b64parts.php
*
* Routes:
*
* /package?name=<package>&next=...
* HTML-pagina met base64 part-links voor data/<package>.zip.
*
* /package-part?name=<package>&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(
'<!doctype html><html><head><meta charset="utf-8">' .
'<title>Package error</title></head><body>' .
'<h1>Package error</h1>' .
'<pre>' . h($message) . '</pre>' .
'</body></html>',
$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 .=
'<tr>' .
'<td>' . h($n) . '</td>' .
'<td>' . h((string)$part['base64_bytes']) . '</td>' .
'<td><a href="' . h($url) . '">' . h($url) . '</a></td>' .
'</tr>' . "\n";
}
html_response('<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>Package ' . h($package) . '</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body class="simple-doc">
<h1>Package ' . h($package) . '</h1>
<p>
Deze pagina is HTML. Alle part-links hieronder geven <code>text/plain</code>
met base64-inhoud terug. Dezelfde <code>next</code> wordt gebruikt voor alle
part-links op deze pagina.
</p>
<h2>Bron</h2>
<table>
<tr><th>catalog source</th><td><code>' . h($info['source']) . '</code></td></tr>
<tr><th>repo url</th><td><code>' . h($info['repo_url']) . '</code></td></tr>
<tr><th>fetch status</th><td><code>' . h($zipInfo['status'] ?? '') . '</code></td></tr>
<tr><th>default branch</th><td><code>' . h($zipInfo['default_branch'] ?? '') . '</code></td></tr>
<tr><th>head sha</th><td><code>' . h($zipInfo['head_sha'] ?? '') . '</code></td></tr>
<tr><th>zip file</th><td><code>' . h($zipInfo['zip_file'] ?? '') . '</code></td></tr>
<tr><th>zip bytes</th><td><code>' . h((string)($zipInfo['zip_bytes'] ?? '')) . '</code></td></tr>
<tr><th>zip sha256</th><td><code>' . h($zipInfo['zip_sha256'] ?? '') . '</code></td></tr>
<tr><th>parts status</th><td><code>' . h($manifest['parts_status'] ?? '') . '</code></td></tr>
<tr><th>max base64 part size</th><td><code>' . h((string)PACKAGE_ZIP_MAX_BASE64_KB) . '</code> KiB (<code>' . h((string)PACKAGE_ZIP_MAX_BASE64_BYTES) . '</code> bytes)</td></tr>
<tr><th>binary chunk size</th><td><code>' . h((string)($manifest['binary_chunk_bytes'] ?? '')) . '</code> bytes</td></tr>
<tr><th>part count</th><td><code>' . h((string)$manifest['part_count']) . '</code></td></tr>
<tr><th>next id</th><td><code>' . h($NEXT_ID) . '</code></td></tr>
</table>
<h2>Base64 parts</h2>
<table>
<thead>
<tr>
<th>part</th>
<th>base64 bytes</th>
<th>text/plain URL</th>
</tr>
</thead>
<tbody>
' . $rows . '
</tbody>
</table>
<h2>Reconstructie in de sandbox</h2>
<pre>
# download alle links als:
# ' . h($package) . '.part.000001.b64
# ' . h($package) . '.part.000002.b64
# enz.
cat ' . h($package) . '.part.*.b64 &gt; ' . h($package) . '.zip.b64
base64 -d ' . h($package) . '.zip.b64 &gt; ' . h($package) . '.zip
raco pkg install --auto ./' . h($package) . '.zip
</pre>
</body>
</html>');
}
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);
+516
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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 = '<span class="diff-muted">' +
esc(uiText.no_previous_versions) +
'</span>';
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('<span class="diff-line diff-same">' + esc(op.oldLine) + '</span>');
} else if (op.type === 'added') {
out.push('<span class="diff-line diff-added">+ ' + esc(op.newLine) + '</span>');
} else if (op.type === 'deleted') {
out.push('<span class="diff-line diff-deleted">- ' + esc(op.oldLine) + '</span>');
} else if (op.type === 'changed') {
out.push(
'<span class="diff-line diff-changed">~ ' +
esc(uiText.old) +
': ' +
esc(op.oldLine) +
'</span>'
);
out.push(
'<span class="diff-line diff-changed">~ ' +
esc(uiText.new) +
': ' +
esc(op.newLine) +
'</span>'
);
}
}
if (out.length === 0) {
return '<span class="diff-muted">' + esc(uiText.no_lines_for_view) + '</span>';
}
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();
});
}());
+720
View File
@@ -0,0 +1,720 @@
<?php
/*
* prompts.php
*
* User/admin prompt administration page.
*
* Normal users:
* - manage personal prompts
* - copy global default prompts to their own prompts
*
* Admin users:
* - same as normal users
* - manage global default prompts
*/
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/header.php';
require_once __DIR__ . '/languagestore.php';
require_once __DIR__ . '/promptstore.php';
require_once __DIR__ . '/usersettings.php';
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
error_reporting(E_ALL);
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
$auth = new RacketSandboxAuth($DB_FILE);
$user = $auth->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');
?>
<!doctype html>
<html lang="<?= h($language) ?>">
<head>
<meta charset="utf-8">
<title><?= h(t('prompts.title', 'Prompt administration')) ?></title>
<link rel="stylesheet" href="/styles.css?v=<?= h($styleVersion) ?>">
</head>
<body>
<div class="page">
<?php
render_app_header(array(
'title' => 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,
));
?>
<main class="page-main prompt-workbench">
<aside class="prompt-sidebar panel">
<div class="prompt-tabs" role="tablist" aria-label="Prompt lists">
<button type="button" class="prompt-tab <?= $mode === 'defaults' ? 'active' : '' ?>" data-tab="defaults" role="tab" aria-selected="<?= $mode === 'defaults' ? 'true' : 'false' ?>">
<?= h(t('prompts.available_defaults', 'Available default prompts')) ?>
</button>
<button type="button" class="prompt-tab <?= $mode !== 'defaults' ? 'active' : '' ?>" data-tab="personal" role="tab" aria-selected="<?= $mode !== 'defaults' ? 'true' : 'false' ?>">
<?= h(t('prompts.your_prompts', 'Your prompts')) ?>
</button>
</div>
<section class="prompt-tab-panel <?= $mode === 'defaults' ? 'active' : '' ?>" id="tab-defaults" role="tabpanel">
<?php if ($user->isAdmin()): ?>
<div class="default-admin-notice">
<strong><?= h(t('prompts.default_admin_badge', 'Admin default prompts')) ?></strong>
<span><?= h(t('prompts.default_admin_hint', 'You are editing global default prompts. Users can copy these to their own prompts.')) ?></span>
</div>
<?php endif; ?>
<div class="sidebar-actions">
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal">
<input type="hidden" name="action" value="copy_all_defaults">
<input type="hidden" name="language" value="<?= h($language) ?>">
<button type="submit"><?= h(t('prompts.copy_all', 'Copy all')) ?></button>
</form>
</div>
<div class="prompt-list">
<?php foreach ($defaultPrompts as $default): ?>
<div class="prompt-list-item default-prompt-item">
<button type="button"
class="prompt-select js-open-prompt"
data-kind="default"
data-id="<?= h($default->id()) ?>">
<span class="prompt-name"><?= h($default->name()) ?></span>
<span class="prompt-subline"><code><?= h($default->defaultKey()) ?></code> · <?= h(fmt_time($default->updatedAt())) ?></span>
</button>
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal">
<input type="hidden" name="action" value="copy_default">
<input type="hidden" name="default_id" value="<?= h($default->id()) ?>">
<button type="submit"><?= h(t('prompts.copy', 'copy')) ?></button>
</form>
<?php if ($user->isAdmin()): ?>
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=defaults"
onsubmit="return confirm('<?= h(t('prompts.delete_default_confirm', 'Delete default prompt')) ?> <?= h($default->name()) ?>?');">
<input type="hidden" name="action" value="delete_default">
<input type="hidden" name="default_id" value="<?= h($default->id()) ?>">
<button type="submit"><?= h(t('prompts.delete', 'delete')) ?></button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (count($defaultPrompts) === 0): ?>
<p class="empty-state"><?= h(t('prompts.no_defaults', 'No default prompts for this language yet.')) ?></p>
<?php endif; ?>
</div>
<?php if ($user->isAdmin()): ?>
<details class="create-drawer">
<summary><?= h(t('prompts.create_default', 'Create default prompt')) ?></summary>
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=defaults">
<input type="hidden" name="action" value="create_default">
<label><?= h(t('prompts.default_key', 'Default key')) ?><br><input type="text" name="default_key" placeholder="bootstrap-racket"></label>
<label><?= h(t('prompts.name', 'Name')) ?><br><input type="text" name="name"></label>
<label><?= h(t('prompts.language', 'Language')) ?><br>
<select name="language">
<?php foreach ($store->supportedLanguages() as $lang): ?>
<option value="<?= h($lang) ?>" <?= $lang === $language ? 'selected' : '' ?>>
<?= h($store->languageLabel($lang)) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label><?= h(t('prompts.prompt_content', 'Prompt content')) ?><br><textarea name="content" rows="7"></textarea></label>
<button type="submit"><?= h(t('prompts.create_default', 'Create default prompt')) ?></button>
</form>
</details>
<?php endif; ?>
</section>
<section class="prompt-tab-panel <?= $mode !== 'defaults' ? 'active' : '' ?>" id="tab-personal" role="tabpanel">
<div class="prompt-list">
<?php foreach ($personalPrompts as $prompt): ?>
<div class="prompt-list-item">
<button type="button"
class="prompt-select js-open-prompt"
data-kind="personal"
data-id="<?= h($prompt->id()) ?>">
<span class="prompt-name"><?= h($prompt->name()) ?></span>
<span class="prompt-subline"><?= h($store->languageLabel($prompt->language())) ?> · <?= h(fmt_time($prompt->updatedAt())) ?></span>
</button>
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal"
onsubmit="return confirm('<?= h(t('prompts.delete_prompt_confirm', 'Delete prompt')) ?> <?= h($prompt->name()) ?>?');">
<input type="hidden" name="action" value="delete_prompt">
<input type="hidden" name="prompt_id" value="<?= h($prompt->id()) ?>">
<button type="submit"><?= h(t('prompts.delete', 'delete')) ?></button>
</form>
</div>
<?php endforeach; ?>
<?php if (count($personalPrompts) === 0): ?>
<p class="empty-state"><?= h(t('prompts.no_personal', 'No personal prompts yet for this language.')) ?></p>
<?php endif; ?>
</div>
<details class="create-drawer">
<summary><?= h(t('prompts.create_personal', 'Create personal prompt')) ?></summary>
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal">
<input type="hidden" name="action" value="create_prompt">
<label><?= h(t('prompts.name', 'Name')) ?><br><input type="text" name="name"></label>
<label><?= h(t('prompts.language', 'Language')) ?><br>
<select name="language">
<?php foreach ($store->supportedLanguages() as $lang): ?>
<option value="<?= h($lang) ?>" <?= $lang === $language ? 'selected' : '' ?>>
<?= h($store->languageLabel($lang)) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label><?= h(t('prompts.prompt_content', 'Prompt content')) ?><br><textarea name="content" rows="7"></textarea></label>
<button type="submit"><?= h(t('prompts.create_personal', 'Create prompt')) ?></button>
</form>
</details>
</section>
</aside>
<section id="promptViewer" class="prompt-viewer panel is-empty">
<div class="viewer-empty">
<?= h(t('prompts.select_prompt', 'Select a prompt on the left to view it.')) ?>
</div>
<div class="viewer-shell">
<div class="viewer-header">
<div>
<div class="viewer-title" id="viewerTitle"><?= h(t('prompts.prompt', 'Prompt')) ?></div>
<div class="version-meta" id="viewerMeta"></div>
</div>
<button type="button" id="editPromptButton"><?= h(t('prompts.edit', 'Edit')) ?></button>
</div>
<pre id="viewerContent" class="viewer-content"></pre>
</div>
</section>
</main>
<footer class="page-footer">
<a href="/"><?= h(t('prompts.back', 'Back to Racket sandbox')) ?></a>
</footer>
</div>
<div id="promptModalBackdrop" class="modal-backdrop">
<div class="prompt-modal">
<form id="promptModalForm" class="edit-mode" method="post" action="/prompts?lang=<?= h($language) ?>&mode=<?= h($mode) ?>">
<div class="modal-header">
<div>
<div class="modal-title" id="modalTitle"><?= h(t('prompts.prompt', 'Prompt')) ?></div>
<div class="version-meta" id="modalMeta"></div>
</div>
<button type="button" id="cancelEditButton"><?= h(t('prompts.close', 'Close')) ?></button>
</div>
<div class="modal-toolbar">
<button type="button" id="versionNewerButton"><?= h(t('prompts.newer_previous', 'newer previous version')) ?></button>
<button type="button" id="versionOlderButton"><?= h(t('prompts.older_previous', 'older previous version')) ?></button>
<label>
<?= h(t('prompts.diff_view', 'Diff view:')) ?>
<select id="diffMode">
<option value="plain"><?= h(t('prompts.diff_plain', 'text, no diff')) ?></option>
<option value="all" selected><?= h(t('prompts.diff_all', 'all diff')) ?></option>
<option value="same"><?= h(t('prompts.diff_same', 'unchanged only')) ?></option>
<option value="added"><?= h(t('prompts.diff_added', 'additions only')) ?></option>
<option value="deleted"><?= h(t('prompts.diff_deleted', 'deletions only')) ?></option>
<option value="changed"><?= h(t('prompts.diff_changed', 'changes only')) ?></option>
</select>
</label>
<span id="versionIndicator" class="version-meta"></span>
</div>
<div class="modal-body">
<div class="edit-pane">
<div class="modal-form-grid">
<label>
<?= h(t('prompts.name', 'Name')) ?><br>
<input type="text" id="modalName" name="name">
</label>
<label>
<?= h(t('prompts.language', 'Language')) ?><br>
<select id="modalLanguage" name="language">
<?php foreach ($store->supportedLanguages() as $lang): ?>
<option value="<?= h($lang) ?>"><?= h($store->languageLabel($lang)) ?></option>
<?php endforeach; ?>
</select>
</label>
</div>
<div id="defaultKeyRow" class="default-key-row">
<label>
<?= h(t('prompts.default_key', 'Default key')) ?><br>
<input type="text" id="modalDefaultKey" name="default_key">
</label>
</div>
<label class="content-label">
<span><?= h(t('prompts.prompt_content', 'Prompt content')) ?></span>
<textarea id="modalContent" name="content"></textarea>
</label>
</div>
<div class="version-pane">
<div>
<strong><?= h(t('prompts.previous_version', 'Previous version')) ?></strong>
<div id="selectedVersionMeta" class="version-meta"></div>
</div>
<div class="version-content" id="versionContent"></div>
</div>
</div>
<div class="modal-footer">
<input type="hidden" id="modalAction" name="action" value="">
<input type="hidden" id="modalPromptId" name="prompt_id" value="">
<input type="hidden" id="modalDefaultId" name="default_id" value="">
<label>
<input type="checkbox" name="create_version" value="1" checked>
<?= h(t('prompts.store_version', 'store this edit as a new version')) ?>
</label>
<label>
<?= h(t('prompts.version_note', 'Version note:')) ?>
<input type="text" name="version_note" value="editor edit">
</label>
<button type="submit"><?= h(t('prompts.save', 'Save')) ?></button>
<button type="button" id="snapshotButton"><?= h(t('prompts.store_snapshot', 'Store snapshot')) ?></button>
<button type="button" id="restoreVersionButton"><?= h(t('prompts.restore_version', 'Restore selected version')) ?></button>
<button type="button" id="deleteVersionButton"><?= h(t('prompts.delete_version', 'Delete selected version')) ?></button>
</div>
</form>
</div>
</div>
<form id="modalAuxForm" method="post" action="/prompts?lang=<?= h($language) ?>&mode=<?= h($mode) ?>" class="hidden">
<input type="hidden" id="auxAction" name="action" value="">
<input type="hidden" id="auxPromptId" name="prompt_id" value="">
<input type="hidden" id="auxDefaultId" name="default_id" value="">
<input type="hidden" id="auxVersionNo" name="version_no" value="">
<input type="hidden" id="auxVersionNote" name="version_note" value="">
</form>
<script type="application/json" id="promptDataJson">
<?= json_encode($promptData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>
</script>
<script type="application/json" id="promptTextJson">
<?= json_encode(array(
'prompt_not_found' => t('prompts.prompt_not_found', 'Prompt not found'),
'no_previous_versions' => t('prompts.no_previous_versions', 'No previous versions stored.'),
'no_lines_for_view' => t('prompts.no_lines_for_view', 'No lines for this view.'),
'restore_version_confirm' => t('prompts.restore_version_confirm', 'Restore version'),
'delete_version_confirm' => t('prompts.delete_version_confirm', 'Delete version'),
'default_prompt_prefix' => t('prompts.default_prompt_prefix', 'Default prompt: '),
'prompt_prefix' => t('prompts.prompt_prefix', 'Prompt: '),
'created' => t('prompts.created', 'created'),
'updated' => t('prompts.updated', 'updated'),
'default_prompt' => t('prompts.default_prompt', 'default prompt'),
'version' => t('prompts.version', 'version'),
'showing_version' => t('prompts.showing_version', 'showing version'),
'of' => t('prompts.of', 'of'),
'old' => t('prompts.old', 'old'),
'new' => t('prompts.new', 'new'),
), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>
</script>
<script src="/prompt-editor.js?v=<?= h($promptEditorVersion) ?>" defer></script>
</body>
</html>
+1075
View File
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
<?php
/*
* Shared handling for the Racket installation zip and its binary parts.
*/
define('RACKET_ZIP_FILE', __DIR__ . '/config/racket.zip');
define('RACKET_ZIP_DATA_DIR', __DIR__ . '/data');
define('RACKET_ZIP_PART_PREFIX', 'racket-part-');
define('RACKET_ZIP_MANIFEST_FILE', RACKET_ZIP_DATA_DIR . '/racket-parts.json');
function racket_zip_binary_chunk_bytes_for_base64_kib($kib)
{
return intdiv(((int)$kib) * 1024, 4) * 3;
}
function racket_zip_part_name($nr)
{
return RACKET_ZIP_PART_PREFIX . sprintf('%06d', $nr);
}
function racket_zip_part_file($partNumber)
{
return RACKET_ZIP_DATA_DIR . '/' . RACKET_ZIP_PART_PREFIX . $partNumber;
}
function racket_zip_ensure_data_dir()
{
if (!is_dir(RACKET_ZIP_DATA_DIR)) {
if (!mkdir(RACKET_ZIP_DATA_DIR, 0755, true)) {
throw new RuntimeException('Kan data directory niet maken: ' . RACKET_ZIP_DATA_DIR);
}
}
if (!is_writable(RACKET_ZIP_DATA_DIR)) {
throw new RuntimeException('Data directory is niet schrijfbaar: ' . RACKET_ZIP_DATA_DIR);
}
}
function racket_zip_remove_old_parts()
{
foreach (glob(RACKET_ZIP_DATA_DIR . '/' . RACKET_ZIP_PART_PREFIX . '*') ?: array() as $file) {
if (is_file($file)) {
@unlink($file);
}
}
if (is_file(RACKET_ZIP_MANIFEST_FILE)) {
@unlink(RACKET_ZIP_MANIFEST_FILE);
}
}
function split_racket_zip_parts($maxBase64Kib)
{
$maxBase64Bytes = (int)$maxBase64Kib * 1024;
$binaryChunkBytes = racket_zip_binary_chunk_bytes_for_base64_kib($maxBase64Kib);
if ($binaryChunkBytes < 3) {
throw new RuntimeException('Ongeldige racket.zip base64 chunk-instelling.');
}
if (!is_file(RACKET_ZIP_FILE) || !is_readable(RACKET_ZIP_FILE)) {
throw new RuntimeException('racket.zip ontbreekt of is niet leesbaar: ' . RACKET_ZIP_FILE);
}
racket_zip_ensure_data_dir();
racket_zip_remove_old_parts();
$in = fopen(RACKET_ZIP_FILE, 'rb');
if ($in === false) {
throw new RuntimeException('Kan racket.zip niet openen.');
}
$parts = array();
$nr = 1;
$totalBinaryBytes = 0;
while (!feof($in)) {
$buf = fread($in, $binaryChunkBytes);
if ($buf === false) {
fclose($in);
throw new RuntimeException('Fout bij lezen van racket.zip.');
}
if ($buf === '') {
break;
}
$name = racket_zip_part_name($nr);
$file = RACKET_ZIP_DATA_DIR . '/' . $name;
if (file_put_contents($file, $buf, LOCK_EX) === false) {
fclose($in);
throw new RuntimeException('Kan part niet schrijven: ' . $file);
}
$n = sprintf('%06d', $nr);
$binaryBytes = strlen($buf);
$parts[] = array(
'number' => $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;
}
+312
View File
@@ -0,0 +1,312 @@
<?php
/*
* Simpele Racket bootstrap.
*
* .htaccess:
*
* FallbackResource /index.php
*
* Bestanden:
*
* config/racket.zip
* data/ bevat vooraf gemaakte parts
*
* Routes:
*
* /bootstrap-racket?next=...
* Leest de vooraf gemaakte parts in data/ en toont een HTML-index
* met links.
*
* /bootstrap-racket-part?n=000001&next=...
* Geeft text/plain met base64 van precies één binair part.
*
* Regels:
*
* - Indexen/lijsten zijn HTML.
* - Payload/downloads zijn text/plain.
* - Payload/downloads zijn altijd base64.
* - Geen .bin, .zip of bestandsnaam in download-URLs.
* - next=... is alleen cache-busting.
* - Per gegenereerde HTML-pagina wordt één next-id gemaakt en voor alle
* links op die pagina gebruikt.
*/
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__ . '/base64config.php';
require_once __DIR__ . '/racketzip.php';
$TOKENS = new NextTokenStore(__DIR__ . '/data/racket-sandbox.sqlite');
@set_time_limit(300);
ignore_user_abort(false);
$chunkConfig = load_base64_chunk_config();
$racketZipMaxBase64Kb = (int)($chunkConfig['racket_zip_max_base64_kb'] ?? 8192);
if ($racketZipMaxBase64Kb < 1) {
$racketZipMaxBase64Kb = 1;
}
define('RACKET_ZIP_MAX_BASE64_KB', $racketZipMaxBase64Kb);
define('RACKET_ZIP_MAX_BASE64_BYTES', RACKET_ZIP_MAX_BASE64_KB * 1024);
define('RACKET_ZIP_BINARY_CHUNK_BYTES', racket_zip_binary_chunk_bytes_for_base64_kib(RACKET_ZIP_MAX_BASE64_KB));
if (RACKET_ZIP_BINARY_CHUNK_BYTES < 3) {
fail_html('Ongeldige racket.zip base64 chunk-instelling.');
}
/*
* Eén next-id voor alle URLs die deze request genereert.
*/
$NEXT_ID = $TOKENS->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(
'<!doctype html>' .
'<html><head><meta charset="utf-8"><title>Error</title></head>' .
'<body><h1>Error</h1><pre>' . h($message) . '</pre></body></html>',
$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 .=
'<tr>' .
'<td>' . h((string)($i + 1)) . '</td>' .
'<td>' . h($part['number']) . '</td>' .
'<td>' . h((string)$part['size']) . '</td>' .
'<td><a href="' . h($part['url']) . '">' . h($part['url']) . '</a></td>' .
'</tr>' . "\n";
}
$zipSize = (int)($manifest['source_bytes'] ?? 0);
$pkg_url = make_url('/racket-pkg-index');
html_response('<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>Racket bootstrap</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body class="simple-doc">
<h1>Racket bootstrap</h1>
<p>
<code>racket.zip</code> is vooraf via de configuratiepagina gesplitst naar
parts in de map <code>data</code>.
</p>
<p>
Package index: <a href="' . $pkg_url . '"> Racket package index</a>
</p>
<p>
Bronbestand: <code>config/racket.zip</code><br>
Bronbestand bytes: <code>' . h((string)$zipSize) . '</code><br>
Maximale base64 part-grootte: <code>' . h((string)RACKET_ZIP_MAX_BASE64_KB) . '</code> KiB (<code>' . h((string)RACKET_ZIP_MAX_BASE64_BYTES) . '</code> bytes)<br>
Binaire chunk-grootte: <code>' . h((string)RACKET_ZIP_BINARY_CHUNK_BYTES) . '</code> bytes<br>
Aantal parts: <code>' . h((string)count($parts)) . '</code><br>
Parts gemaakt op: <code>' . h((string)($manifest['created_at'] ?? '')) . '</code><br>
next-id voor alle links op deze pagina: <code>' . h($NEXT_ID) . '</code>
</p>
<p>
Elke link hieronder geeft <strong>text/plain</strong> met de
<strong>base64-representatie van één binair part</strong>.
De URL bevat alleen een nummer, geen bestandsnaam en geen extensie.
</p>
<table>
<thead>
<tr>
<th>#</th>
<th>partnummer</th>
<th>bytes</th>
<th>base64 text/plain URL</th>
</tr>
</thead>
<tbody>
' . $rows . '
</tbody>
</table>
<h2>Reconstructie in de sandbox</h2>
<p>
Decodeer ieder base64-part afzonderlijk naar een binair part. Plak daarna de
binaire parts in numerieke volgorde aan elkaar.
</p>
<pre>
base64 -d part-000001.txt &gt; part-000001
base64 -d part-000002.txt &gt; part-000002
base64 -d part-000003.txt &gt; part-000003
# enzovoort
cat part-* &gt; racket.zip
unzip racket.zip -d /tmp/racket
</pre>
</body>
</html>');
}
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);
+187
View File
@@ -0,0 +1,187 @@
<?php
/*
* rktpkgs.php
*
* Maakt een volledige HTML-index van de Racket package catalogus.
*
* Bron:
* https://pkgs.racket-lang.org/pkgs-all
*
* Output:
* Alleen HTML.
*
* Package-links:
* /package?name=<pkg-name>&next=<id>
*
* 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(
'<!doctype html>' .
'<html><head><meta charset="utf-8"><title>Error</title></head>' .
'<body><h1>Error</h1><pre>' . h($message) . '</pre></body></html>',
$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 .=
'<tr id="pkg-' . h($name) . '">' .
'<td>' . h((string)($i + 1)) . '</td>' .
'<td class="pkg-name">' .
'<a class="pkg-link" href="' . h($url) . '">' . h($name) . '</a>' .
'</td>' .
'</tr>' . "\n";
}
html_response('<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>Racket package index</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body class="simple-doc package-index-page">
<h1>Racket package index</h1>
<p>
Volledige HTML-index van de Racket package catalogus op basis van
<code>pkgs-all</code>. De package-naam is de ophaallink via
<code>rktsndbx.dijkewijk.nl</code>.
</p>
<p>
Aantal packages: <code>' . h((string)count($names)) . '</code><br>
next-id voor alle package-links op deze pagina:
<code>' . h($NEXT_ID) . '</code>
</p>
<table>
<thead>
<tr>
<th>#</th>
<th>package</th>
</tr>
</thead>
<tbody>
' . $rows . '
</tbody>
</table>
</body>
</html>');
}
$TOKENS->check_valid_next('html');
serve_index();
+932
View File
@@ -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;
}
+297
View File
@@ -0,0 +1,297 @@
<?php
/*
* users.php
*
* Admin user management.
*/
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/header.php';
require_once __DIR__ . '/languagestore.php';
require_once __DIR__ . '/usersettings.php';
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
error_reporting(E_ALL);
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
$auth = new RacketSandboxAuth($DB_FILE);
$languageStore = new LanguageStore($DB_FILE);
$userSettings = new UserSettingsStore($DB_FILE);
$currentUser = $auth->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');
?>
<!doctype html>
<html lang="<?= h($language) ?>">
<head>
<meta charset="utf-8">
<title><?= h(t('app.user_management', 'User management')) ?></title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="page">
<?php
render_app_header(array(
'title' => 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,
));
?>
<main class="page-main dashboard-main">
<section class="panel">
<h2><?= h(t('app.create_user', 'Create user')) ?></h2>
<form method="post" action="/users?lang=<?= h($language) ?>" class="admin-form-grid">
<input type="hidden" name="action" value="create_user">
<label><?= h(t('app.full_name', 'Full name')) ?><br><input type="text" name="full_name" required></label>
<label><?= h(t('app.email', 'Email')) ?><br><input type="email" name="email" required></label>
<label><?= h(t('app.password', 'Password')) ?><br><input type="password" name="password" autocomplete="new-password" required></label>
<label><input type="checkbox" name="is_admin" value="1"> <?= h(t('app.admin', 'Admin')) ?></label>
<label><input type="checkbox" name="is_enabled" value="1" checked> <?= h(t('app.enabled', 'Enabled')) ?></label>
<button type="submit"><?= h(t('app.create_user', 'Create user')) ?></button>
</form>
</section>
<section class="panel">
<h2><?= h(t('app.user_management', 'User management')) ?></h2>
<table>
<thead>
<tr>
<th><?= h(t('app.full_name', 'Full name')) ?></th>
<th><?= h(t('app.email', 'Email')) ?></th>
<th><?= h(t('app.admin', 'Admin')) ?></th>
<th><?= h(t('app.enabled', 'Enabled')) ?></th>
<th><?= h(t('app.created', 'Created')) ?></th>
<th><?= h(t('app.last_login', 'Last login')) ?></th>
<th><?= h(t('app.actions', 'Actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $managedUser): ?>
<tr>
<td colspan="7">
<form method="post" action="/users?lang=<?= h($language) ?>" class="user-row-form">
<input type="hidden" name="action" value="update_user">
<input type="hidden" name="user_id" value="<?= h($managedUser->id()) ?>">
<label><?= h(t('app.full_name', 'Full name')) ?><br><input type="text" name="full_name" value="<?= h($managedUser->fullName()) ?>" required></label>
<label><?= h(t('app.email', 'Email')) ?><br><input type="email" name="email" value="<?= h($managedUser->email()) ?>" required></label>
<label><input type="checkbox" name="is_admin" value="1" <?= $managedUser->isAdmin() ? 'checked' : '' ?>> <?= h(t('app.admin', 'Admin')) ?></label>
<label><input type="checkbox" name="is_enabled" value="1" <?= $managedUser->isEnabled() ? 'checked' : '' ?>> <?= h(t('app.enabled', 'Enabled')) ?></label>
<span><?= h(fmt_time($managedUser->createdAt())) ?></span>
<span><?= h(fmt_time($managedUser->lastLoginAt())) ?></span>
<button type="submit"><?= h(t('app.update_user', 'Update user')) ?></button>
</form>
<div class="user-row-actions">
<form method="post" action="/users?lang=<?= h($language) ?>">
<input type="hidden" name="action" value="set_password">
<input type="hidden" name="email" value="<?= h($managedUser->email()) ?>">
<label><?= h(t('app.new_password', 'New password')) ?><br><input type="password" name="password" autocomplete="new-password"></label>
<button type="submit"><?= h(t('app.change_password', 'Change password')) ?></button>
</form>
<?php if ($managedUser->id() !== $currentUser->id()): ?>
<form method="post" action="/users?lang=<?= h($language) ?>"
onsubmit="return confirm('<?= h(t('app.delete_user_confirm', 'Delete user')) ?> <?= h($managedUser->email()) ?>?');">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="user_id" value="<?= h($managedUser->id()) ?>">
<button type="submit"><?= h(t('app.delete_user', 'Delete user')) ?></button>
</form>
<?php else: ?>
<p class="small"><?= h(t('app.cannot_delete_self', 'You cannot delete your own account.')) ?></p>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
</main>
</div>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
<?php
/*
* usersettings.php
*
* Tiny SQLite-backed key/value store for per-user UI preferences.
*/
class UserSettingsStoreException extends Exception
{
}
class UserSettingsStore
{
private $db;
public function __construct($dbFile)
{
$dir = dirname($dbFile);
if (!is_dir($dir)) {
if (!mkdir($dir, 0700, true)) {
throw new UserSettingsStoreException('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()
{
$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;
}
}