Reorganize PHP internals and static assets

Move shared PHP code into private/, move JavaScript files into js/, and block direct access to private/. Remove unused API key and cache artifacts from the working tree.
This commit is contained in:
www-data
2026-05-26 11:32:36 +02:00
parent 97f23260ed
commit 2f2e8869d6
30 changed files with 48 additions and 48 deletions
+1
View File
@@ -0,0 +1 @@
Require all denied
+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.');
}
}
+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,
);
+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),
),
);
+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'], '/')
: dirname(__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
}
+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;
}
+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(dirname(__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,
));
}
}
}
}
}
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', dirname(__DIR__) . '/config/racket.zip');
define('RACKET_ZIP_DATA_DIR', dirname(__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;
}
+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;
}
}