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:
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user