initial import
This commit is contained in:
@@ -42,3 +42,7 @@ compiled/
|
||||
/app/Config/database.php
|
||||
/vendors/*
|
||||
|
||||
|
||||
.rktsndbx-cache
|
||||
data
|
||||
pkg-cache
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
FallbackResource /index.php
|
||||
|
||||
DirectoryIndex index.php
|
||||
|
||||
Options -MultiViews -Indexes
|
||||
RewriteEngine On
|
||||
|
||||
RewriteRule ^bootstrap-racket$ rkt.php [L,QSA]
|
||||
RewriteRule ^bootstrap-racket-part$ rkt.php [L,QSA]
|
||||
RewriteRule ^racket-pkg-index$ rktpkgs.php [L,QSA]
|
||||
RewriteRule ^package$ package.php [L,QSA]
|
||||
RewriteRule ^package-part$ package.php [L,QSA]
|
||||
RewriteRule ^login$ login.php [L,QSA]
|
||||
RewriteRule ^bootstrap-admin$ bootstrap-admin.php [L,QSA]
|
||||
RewriteRule ^prompts$ prompts.php [L,QSA]
|
||||
RewriteRule ^users$ users.php [L,QSA]
|
||||
RewriteRule ^admin-config$ config.php [L,QSA]
|
||||
@@ -0,0 +1,722 @@
|
||||
<?php
|
||||
/*
|
||||
* auth.php
|
||||
*
|
||||
* Encapsulated authentication/user module for the Racket sandbox.
|
||||
*
|
||||
* Database:
|
||||
* data/racket-sandbox.sqlite
|
||||
*
|
||||
* Public model:
|
||||
* RacketSandboxAuth
|
||||
* RacketSandboxUser
|
||||
*
|
||||
* Outside code should not inspect DB rows directly.
|
||||
*/
|
||||
|
||||
class RacketSandboxAuthException extends Exception
|
||||
{
|
||||
}
|
||||
|
||||
class RacketSandboxUser
|
||||
{
|
||||
private $row;
|
||||
|
||||
public function __construct($row)
|
||||
{
|
||||
$this->row = is_array($row) ? $row : array();
|
||||
}
|
||||
|
||||
public function id()
|
||||
{
|
||||
return (int)($this->row['id'] ?? 0);
|
||||
}
|
||||
|
||||
public function email()
|
||||
{
|
||||
return (string)($this->row['email'] ?? '');
|
||||
}
|
||||
|
||||
public function fullName()
|
||||
{
|
||||
return (string)($this->row['full_name'] ?? '');
|
||||
}
|
||||
|
||||
public function displayName()
|
||||
{
|
||||
$name = $this->fullName();
|
||||
$email = $this->email();
|
||||
|
||||
if ($name !== '' && $email !== '') {
|
||||
return $name . ' <' . $email . '>';
|
||||
}
|
||||
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
public function isAdmin()
|
||||
{
|
||||
return (int)($this->row['is_admin'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public function isEnabled()
|
||||
{
|
||||
return (int)($this->row['is_enabled'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public function createdAt()
|
||||
{
|
||||
return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null;
|
||||
}
|
||||
|
||||
public function updatedAt()
|
||||
{
|
||||
return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null;
|
||||
}
|
||||
|
||||
public function lastLoginAt()
|
||||
{
|
||||
return isset($this->row['last_login_at']) && $this->row['last_login_at'] !== null
|
||||
? (int)$this->row['last_login_at']
|
||||
: null;
|
||||
}
|
||||
|
||||
public function sessionExpiresAt()
|
||||
{
|
||||
return isset($this->row['session_expires_at']) && $this->row['session_expires_at'] !== null
|
||||
? (int)$this->row['session_expires_at']
|
||||
: null;
|
||||
}
|
||||
|
||||
public function raw()
|
||||
{
|
||||
return $this->row;
|
||||
}
|
||||
}
|
||||
|
||||
class RacketSandboxAuth
|
||||
{
|
||||
private $db;
|
||||
private $sessionCookieName;
|
||||
private $sessionTtlSeconds;
|
||||
|
||||
public function __construct($dbFile, $options = array())
|
||||
{
|
||||
$dir = dirname($dbFile);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0700, true)) {
|
||||
throw new RacketSandboxAuthException('Could not create database directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
$this->sessionCookieName = isset($options['cookie_name'])
|
||||
? (string)$options['cookie_name']
|
||||
: 'rkt_sandbox_session';
|
||||
|
||||
$this->sessionTtlSeconds = isset($options['session_ttl'])
|
||||
? (int)$options['session_ttl']
|
||||
: 12 * 3600;
|
||||
|
||||
$this->db = new PDO('sqlite:' . $dbFile);
|
||||
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$this->db->exec('PRAGMA journal_mode = WAL');
|
||||
$this->db->exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
$this->init();
|
||||
}
|
||||
|
||||
private function init()
|
||||
{
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
full_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER NULL
|
||||
)'
|
||||
);
|
||||
|
||||
$this->migrateUsersTable();
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
user_agent TEXT NULL,
|
||||
remote_addr TEXT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)'
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
|
||||
ON auth_sessions(user_id)'
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires
|
||||
ON auth_sessions(expires_at)'
|
||||
);
|
||||
}
|
||||
|
||||
private function migrateUsersTable()
|
||||
{
|
||||
$cols = $this->tableColumns('users');
|
||||
|
||||
if (isset($cols['username']) && !isset($cols['email'])) {
|
||||
$this->db->exec('ALTER TABLE users RENAME COLUMN username TO email');
|
||||
$cols = $this->tableColumns('users');
|
||||
}
|
||||
|
||||
if (!isset($cols['full_name'])) {
|
||||
$this->db->exec("ALTER TABLE users ADD COLUMN full_name TEXT NOT NULL DEFAULT ''");
|
||||
$this->db->exec("UPDATE users SET full_name = email WHERE full_name = ''");
|
||||
}
|
||||
}
|
||||
|
||||
private function tableColumns($table)
|
||||
{
|
||||
$stmt = $this->db->query('PRAGMA table_info(' . $table . ')');
|
||||
$cols = array();
|
||||
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cols[$row['name']] = true;
|
||||
}
|
||||
|
||||
return $cols;
|
||||
}
|
||||
|
||||
public function createUser($email, $fullName, $password, $isAdmin = false, $enabled = true)
|
||||
{
|
||||
$email = $this->safeEmail($email);
|
||||
$fullName = $this->safeFullName($fullName);
|
||||
$this->assertPasswordUsable($password);
|
||||
|
||||
$now = time();
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
if ($hash === false) {
|
||||
throw new RacketSandboxAuthException('Could not hash password.');
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO users
|
||||
(email, full_name, password_hash, is_admin, is_enabled, created_at, updated_at)
|
||||
VALUES
|
||||
(:email, :full_name, :password_hash, :is_admin, :is_enabled, :created_at, :updated_at)'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':email' => $email,
|
||||
':full_name' => $fullName,
|
||||
':password_hash' => $hash,
|
||||
':is_admin' => $isAdmin ? 1 : 0,
|
||||
':is_enabled' => $enabled ? 1 : 0,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
));
|
||||
|
||||
return $this->getUserById((int)$this->db->lastInsertId());
|
||||
}
|
||||
|
||||
public function login($email, $password)
|
||||
{
|
||||
$email = $this->safeEmail($email);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT *
|
||||
FROM users
|
||||
WHERE email = :email'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':email' => $email));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new RacketSandboxAuthException('Invalid email address or password.');
|
||||
}
|
||||
|
||||
if ((int)$row['is_enabled'] !== 1) {
|
||||
throw new RacketSandboxAuthException('This account is disabled.');
|
||||
}
|
||||
|
||||
if (!password_verify($password, $row['password_hash'])) {
|
||||
throw new RacketSandboxAuthException('Invalid email address or password.');
|
||||
}
|
||||
|
||||
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
|
||||
$this->setPasswordById((int)$row['id'], $password, false);
|
||||
}
|
||||
|
||||
$token = $this->createSession((int)$row['id']);
|
||||
$this->setSessionCookie($token);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET last_login_at = :last_login_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':last_login_at' => time(),
|
||||
':id' => (int)$row['id'],
|
||||
));
|
||||
|
||||
return $this->getUserById((int)$row['id']);
|
||||
}
|
||||
|
||||
public function logout()
|
||||
{
|
||||
$token = $this->cookieToken();
|
||||
|
||||
if ($token !== null) {
|
||||
$this->deleteSession($token);
|
||||
}
|
||||
|
||||
$this->clearSessionCookie();
|
||||
}
|
||||
|
||||
public function currentUser()
|
||||
{
|
||||
$token = $this->cookieToken();
|
||||
|
||||
if ($token === null || !$this->validTokenSyntax($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.full_name,
|
||||
u.is_admin,
|
||||
u.is_enabled,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.last_login_at,
|
||||
s.expires_at AS session_expires_at
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = :token'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':token' => $token));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int)$row['is_enabled'] !== 1) {
|
||||
$this->deleteSession($token);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (time() > (int)$row['session_expires_at']) {
|
||||
$this->deleteSession($token);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->touchSession($token);
|
||||
|
||||
return new RacketSandboxUser($row);
|
||||
}
|
||||
|
||||
public function requireLoginHtml()
|
||||
{
|
||||
$user = $this->currentUser();
|
||||
|
||||
if ($user !== null) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$this->loginRequiredHtml();
|
||||
}
|
||||
|
||||
public function requireAdminHtml()
|
||||
{
|
||||
$user = $this->requireLoginHtml();
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$this->messageHtml(
|
||||
'Not available',
|
||||
'Admin rights are required for this page.'
|
||||
);
|
||||
}
|
||||
|
||||
public function listUsers()
|
||||
{
|
||||
$stmt = $this->db->query(
|
||||
'SELECT
|
||||
id, email, full_name, is_admin, is_enabled,
|
||||
created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
ORDER BY email COLLATE NOCASE'
|
||||
);
|
||||
|
||||
$out = array();
|
||||
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = new RacketSandboxUser($row);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function getUser($id)
|
||||
{
|
||||
return $this->getUserById($id);
|
||||
}
|
||||
|
||||
public function setAdmin($userId, $isAdmin)
|
||||
{
|
||||
$this->updateUserFlag($userId, 'is_admin', $isAdmin);
|
||||
}
|
||||
|
||||
public function setEnabled($userId, $isEnabled)
|
||||
{
|
||||
$this->updateUserFlag($userId, 'is_enabled', $isEnabled);
|
||||
|
||||
if (!$isEnabled) {
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE user_id = :user_id'
|
||||
);
|
||||
$stmt->execute(array(':user_id' => (int)$userId));
|
||||
}
|
||||
}
|
||||
|
||||
public function updateUser($userId, $email, $fullName)
|
||||
{
|
||||
$userId = (int)$userId;
|
||||
$email = $this->safeEmail($email);
|
||||
$fullName = $this->safeFullName($fullName);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET email = :email,
|
||||
full_name = :full_name,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':email' => $email,
|
||||
':full_name' => $fullName,
|
||||
':updated_at' => time(),
|
||||
':id' => $userId,
|
||||
));
|
||||
|
||||
return $this->getUserById($userId);
|
||||
}
|
||||
|
||||
public function setPassword($email, $newPassword)
|
||||
{
|
||||
$email = $this->safeEmail($email);
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT id
|
||||
FROM users
|
||||
WHERE email = :email'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':email' => $email));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new RacketSandboxAuthException('Unknown user: ' . $email);
|
||||
}
|
||||
|
||||
$this->setPasswordById((int)$row['id'], $newPassword, true);
|
||||
}
|
||||
|
||||
public function deleteUser($userId)
|
||||
{
|
||||
$userId = (int)$userId;
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE user_id = :user_id'
|
||||
);
|
||||
$stmt->execute(array(':user_id' => $userId));
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM users
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute(array(':id' => $userId));
|
||||
|
||||
$this->db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function cleanupSessions()
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE expires_at < :now'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':now' => time()));
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
private function getUserById($id)
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT
|
||||
id, email, full_name, is_admin, is_enabled,
|
||||
created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':id' => (int)$id));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RacketSandboxUser($row);
|
||||
}
|
||||
|
||||
private function createSession($userId)
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$now = time();
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO auth_sessions
|
||||
(token, user_id, created_at, expires_at, last_seen_at, user_agent, remote_addr)
|
||||
VALUES
|
||||
(:token, :user_id, :created_at, :expires_at, :last_seen_at, :user_agent, :remote_addr)'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':token' => $token,
|
||||
':user_id' => (int)$userId,
|
||||
':created_at' => $now,
|
||||
':expires_at' => $now + $this->sessionTtlSeconds,
|
||||
':last_seen_at' => $now,
|
||||
':user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
':remote_addr' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function touchSession($token)
|
||||
{
|
||||
$now = time();
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE auth_sessions
|
||||
SET last_seen_at = :last_seen_at,
|
||||
expires_at = :expires_at
|
||||
WHERE token = :token'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':last_seen_at' => $now,
|
||||
':expires_at' => $now + $this->sessionTtlSeconds,
|
||||
':token' => $token,
|
||||
));
|
||||
}
|
||||
|
||||
private function deleteSession($token)
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE token = :token'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':token' => $token));
|
||||
}
|
||||
|
||||
private function setPasswordById($userId, $newPassword, $deleteSessions)
|
||||
{
|
||||
$this->assertPasswordUsable($newPassword);
|
||||
|
||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
|
||||
if ($hash === false) {
|
||||
throw new RacketSandboxAuthException('Could not hash password.');
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET password_hash = :password_hash,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':password_hash' => $hash,
|
||||
':updated_at' => time(),
|
||||
':id' => (int)$userId,
|
||||
));
|
||||
|
||||
if ($deleteSessions) {
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM auth_sessions
|
||||
WHERE user_id = :user_id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':user_id' => (int)$userId));
|
||||
}
|
||||
}
|
||||
|
||||
private function updateUserFlag($userId, $column, $value)
|
||||
{
|
||||
if ($column !== 'is_admin' && $column !== 'is_enabled') {
|
||||
throw new RacketSandboxAuthException('Invalid flag column.');
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE users
|
||||
SET ' . $column . ' = :value,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute(array(
|
||||
':value' => $value ? 1 : 0,
|
||||
':updated_at' => time(),
|
||||
':id' => (int)$userId,
|
||||
));
|
||||
}
|
||||
|
||||
private function cookieToken()
|
||||
{
|
||||
$token = $_COOKIE[$this->sessionCookieName] ?? null;
|
||||
|
||||
if (!is_string($token) || !$this->validTokenSyntax($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function setSessionCookie($token)
|
||||
{
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
setcookie($this->sessionCookieName, $token, array(
|
||||
'expires' => time() + $this->sessionTtlSeconds,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
));
|
||||
}
|
||||
|
||||
private function clearSessionCookie()
|
||||
{
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
setcookie($this->sessionCookieName, '', array(
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
));
|
||||
}
|
||||
|
||||
private function validTokenSyntax($token)
|
||||
{
|
||||
return is_string($token) && preg_match('/^[a-f0-9]{64}$/', $token);
|
||||
}
|
||||
|
||||
private function safeEmail($email)
|
||||
{
|
||||
$email = strtolower(trim((string)$email));
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RacketSandboxAuthException('Invalid email address.');
|
||||
}
|
||||
|
||||
if (strlen($email) > 160) {
|
||||
throw new RacketSandboxAuthException('Email address is too long.');
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function safeFullName($fullName)
|
||||
{
|
||||
$fullName = trim((string)$fullName);
|
||||
|
||||
if ($fullName === '') {
|
||||
throw new RacketSandboxAuthException('Full name is required.');
|
||||
}
|
||||
|
||||
if (strlen($fullName) > 160) {
|
||||
throw new RacketSandboxAuthException('Full name is too long.');
|
||||
}
|
||||
|
||||
return $fullName;
|
||||
}
|
||||
|
||||
private function assertPasswordUsable($password)
|
||||
{
|
||||
if (!is_string($password) || strlen($password) < 10) {
|
||||
throw new RacketSandboxAuthException('Password must be at least 10 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
private function loginRequiredHtml()
|
||||
{
|
||||
$this->messageHtml(
|
||||
'Login required',
|
||||
'Please log in to continue.',
|
||||
'<p><a href="/login.php">Login</a></p>'
|
||||
);
|
||||
}
|
||||
|
||||
private function messageHtml($title, $message, $extra = '')
|
||||
{
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
echo '<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>
|
||||
<p>' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>
|
||||
' . $extra . '
|
||||
</body>
|
||||
</html>';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
+264
@@ -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.');
|
||||
}
|
||||
}
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function renderPrompt(data, select, output) {
|
||||
const selectedId = select.value;
|
||||
const prompt = (data.prompts || []).find(function (item) {
|
||||
return String(item.id) === String(selectedId);
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
output.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
output.value = String(prompt.content || '').split('{{bootstrap-racket-link}}').join(data.link || '');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const dataEl = byId('bootstrapPromptData');
|
||||
const select = byId('bootstrapPromptSelect');
|
||||
const output = byId('bootstrapPromptOutput');
|
||||
|
||||
if (!dataEl || !select || !output) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = { link: '', prompts: [] };
|
||||
|
||||
try {
|
||||
data = JSON.parse(dataEl.textContent || '{}');
|
||||
} catch (e) {
|
||||
data = { link: '', prompts: [] };
|
||||
}
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
renderPrompt(data, select, output);
|
||||
});
|
||||
|
||||
renderPrompt(data, select, output);
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,56 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = text;
|
||||
input.setAttribute('readonly', 'readonly');
|
||||
input.style.position = 'fixed';
|
||||
input.style.left = '-9999px';
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
fallbackCopy(text);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function showCopied(button) {
|
||||
const original = button.dataset.copyLabel || button.textContent;
|
||||
const copied = button.dataset.copiedLabel || 'Copied';
|
||||
|
||||
button.textContent = copied;
|
||||
window.setTimeout(function () {
|
||||
button.textContent = original;
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.js-copy-button').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
let text = button.dataset.copyText || '';
|
||||
const targetId = button.dataset.copyTarget || '';
|
||||
const target = targetId ? document.getElementById(targetId) : null;
|
||||
|
||||
if (target) {
|
||||
text = target.value || target.textContent || '';
|
||||
}
|
||||
|
||||
copyText(text).then(function () {
|
||||
showCopied(button);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
/*
|
||||
* config.php
|
||||
*
|
||||
* Admin application configuration.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/header.php';
|
||||
require_once __DIR__ . '/languagestore.php';
|
||||
require_once __DIR__ . '/nexttoken.php';
|
||||
require_once __DIR__ . '/usersettings.php';
|
||||
require_once __DIR__ . '/base64config.php';
|
||||
require_once __DIR__ . '/racketzip.php';
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
|
||||
|
||||
$auth = new RacketSandboxAuth($DB_FILE);
|
||||
$languageStore = new LanguageStore($DB_FILE);
|
||||
$tokens = new NextTokenStore($DB_FILE);
|
||||
$userSettings = new UserSettingsStore($DB_FILE);
|
||||
$currentUser = $auth->requireAdminHtml();
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
$base64ChunkConfig = load_base64_chunk_config();
|
||||
$localTimezone = new DateTimeZone('Europe/Amsterdam');
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function t($key, $fallback = null)
|
||||
{
|
||||
global $languageStore, $language;
|
||||
|
||||
return $languageStore->translate($key, $language, $fallback);
|
||||
}
|
||||
|
||||
function post_value($name, $default = '')
|
||||
{
|
||||
return $_POST[$name] ?? $default;
|
||||
}
|
||||
|
||||
function resolve_user_language($userSettings, $userId, $allowedLanguages)
|
||||
{
|
||||
$language = isset($_GET['lang'])
|
||||
? (string)$_GET['lang']
|
||||
: (string)$userSettings->get($userId, 'language', 'en');
|
||||
|
||||
if (!in_array($language, $allowedLanguages, true)) {
|
||||
$language = 'en';
|
||||
}
|
||||
|
||||
$userSettings->set($userId, 'language', $language);
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
function base64_binary_chunk_bytes_for_kib($kib)
|
||||
{
|
||||
return racket_zip_binary_chunk_bytes_for_base64_kib($kib);
|
||||
}
|
||||
|
||||
function format_token_time($timestamp)
|
||||
{
|
||||
global $localTimezone;
|
||||
|
||||
$time = (new DateTimeImmutable('@' . (int)$timestamp))->setTimezone($localTimezone);
|
||||
|
||||
return $time->format('Y-m-d H:i:s T');
|
||||
}
|
||||
|
||||
function token_status_class($token)
|
||||
{
|
||||
return time() <= (int)($token['expires_at'] ?? 0)
|
||||
? 'token-valid'
|
||||
: 'token-expired';
|
||||
}
|
||||
|
||||
$language = resolve_user_language(
|
||||
$userSettings,
|
||||
$currentUser->id(),
|
||||
$languageStore->supportedLanguages()
|
||||
);
|
||||
|
||||
$languageStore->seedDefaults(array(
|
||||
'app.title' => array('en' => 'Racket sandbox', 'nl' => 'Racket sandbox'),
|
||||
'app.manage_prompts' => array('en' => 'Manage prompts', 'nl' => 'Prompts beheren'),
|
||||
'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'),
|
||||
'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'),
|
||||
'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'),
|
||||
'app.language' => array('en' => 'Language', 'nl' => 'Taal'),
|
||||
'app.logged_in_as' => array('en' => 'Logged in as:', 'nl' => 'Ingelogd als:'),
|
||||
'app.admin' => array('en' => 'Admin', 'nl' => 'Admin'),
|
||||
'app.back_to_sandbox' => array('en' => 'Back to Racket sandbox', 'nl' => 'Terug naar Racket sandbox'),
|
||||
'app.download_settings' => array('en' => 'Download settings', 'nl' => 'Downloadinstellingen'),
|
||||
'app.maintenance' => array('en' => 'Maintenance', 'nl' => 'Onderhoud'),
|
||||
'app.next_tokens' => array('en' => 'Next tokens', 'nl' => 'Next-tokens'),
|
||||
'app.current_next_tokens' => array('en' => 'Next tokens', 'nl' => 'Next-tokens'),
|
||||
'app.token' => array('en' => 'Token', 'nl' => 'Token'),
|
||||
'app.created_at' => array('en' => 'Created at', 'nl' => 'Aangemaakt op'),
|
||||
'app.expires_at' => array('en' => 'Expires at', 'nl' => 'Verloopt op'),
|
||||
'app.no_current_next_tokens' => array('en' => 'No next tokens.', 'nl' => 'Geen next-tokens.'),
|
||||
'app.remove_expired_tokens' => array('en' => 'Remove expired next tokens', 'nl' => 'Verlopen next-tokens verwijderen'),
|
||||
'app.racket_zip_chunk_kb' => array('en' => 'Racket installation max base64 chunk size (KiB)', 'nl' => 'Maximale base64-chunkgrootte Racket-installatie (KiB)'),
|
||||
'app.package_zip_chunk_kb' => array('en' => 'Package/module max base64 chunk size (KiB)', 'nl' => 'Maximale base64-chunkgrootte packages/modules (KiB)'),
|
||||
'app.save_configuration' => array('en' => 'Save configuration', 'nl' => 'Configuratie opslaan'),
|
||||
'app.configuration_saved' => array('en' => 'Configuration saved.', 'nl' => 'Configuratie opgeslagen.'),
|
||||
'app.racket_parts_regenerated' => array('en' => 'Racket installation parts regenerated:', 'nl' => 'Racket-installatie parts opnieuw gemaakt:'),
|
||||
'app.chunk_size_hint_v2' => array(
|
||||
'en' => 'Values are maximum base64 payload sizes in KiB. A 6144 KiB binary chunk becomes 8192 KiB base64. Racket installation parts are regenerated when this configuration is saved.',
|
||||
'nl' => 'Waarden zijn maximale base64-payloadgroottes in KiB. Een binaire chunk van 6144 KiB wordt 8192 KiB base64. Racket-installatie parts worden opnieuw gemaakt wanneer deze configuratie wordt opgeslagen.',
|
||||
),
|
||||
'app.effective_binary_chunk' => array('en' => 'Effective binary chunk', 'nl' => 'Effectieve binaire chunk'),
|
||||
'app.racket_zip_source' => array('en' => 'Racket installation source', 'nl' => 'Bronbestand Racket-installatie'),
|
||||
'app.racket_parts' => array('en' => 'Racket installation parts', 'nl' => 'Racket-installatie parts'),
|
||||
'app.racket_parts_current' => array('en' => 'current', 'nl' => 'actueel'),
|
||||
'app.racket_parts_missing' => array('en' => 'missing or outdated; save configuration to regenerate', 'nl' => 'ontbreken of verouderd; sla configuratie op om opnieuw te maken'),
|
||||
'app.expired_tokens_removed' => array('en' => 'Expired next tokens removed:', 'nl' => 'Verlopen next-tokens verwijderd:'),
|
||||
'app.cleanup_help' => array(
|
||||
'en' => 'Expired links should return an outdated information message to the AI agent. Cleanup only removes old token rows from SQLite.',
|
||||
'nl' => 'Verlopen links moeten een melding over verouderde informatie aan de AI-agent teruggeven. Opruimen verwijdert alleen oude tokenrijen uit SQLite.'
|
||||
),
|
||||
));
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = post_value('action');
|
||||
|
||||
try {
|
||||
if ($action === 'logout') {
|
||||
$auth->logout();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
} elseif ($action === 'update_config') {
|
||||
save_base64_chunk_config(array(
|
||||
'racket_zip_max_base64_kb' => post_value('racket_zip_max_base64_kb'),
|
||||
'package_zip_max_base64_kb' => post_value('package_zip_max_base64_kb'),
|
||||
));
|
||||
$base64ChunkConfig = load_base64_chunk_config();
|
||||
$manifest = split_racket_zip_parts($base64ChunkConfig['racket_zip_max_base64_kb']);
|
||||
$message = t('app.configuration_saved', 'Configuration saved.') . ' ' .
|
||||
t('app.racket_parts_regenerated', 'Racket installation parts regenerated:') . ' ' .
|
||||
(int)$manifest['part_count'];
|
||||
} elseif ($action === 'cleanup_tokens') {
|
||||
$deleted = $tokens->cleanup();
|
||||
$message = t('app.expired_tokens_removed', 'Expired next tokens removed:') . ' ' . $deleted;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$racketPartsManifest = null;
|
||||
$racketPartsCurrent = false;
|
||||
|
||||
try {
|
||||
$racketPartsManifest = load_racket_zip_parts_manifest();
|
||||
$racketPartsCurrent = racket_zip_parts_current(
|
||||
$racketPartsManifest,
|
||||
$base64ChunkConfig['racket_zip_max_base64_kb']
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$racketPartsManifest = null;
|
||||
$racketPartsCurrent = false;
|
||||
}
|
||||
|
||||
if (!$racketPartsCurrent && is_file(RACKET_ZIP_FILE) && is_readable(RACKET_ZIP_FILE)) {
|
||||
try {
|
||||
$racketPartsManifest = split_racket_zip_parts($base64ChunkConfig['racket_zip_max_base64_kb']);
|
||||
$racketPartsCurrent = true;
|
||||
|
||||
if ($message === '') {
|
||||
$message = t('app.racket_parts_regenerated', 'Racket installation parts regenerated:') . ' ' .
|
||||
(int)$racketPartsManifest['part_count'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($error === '') {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$headerLanguages = array();
|
||||
|
||||
$currentNextTokens = array();
|
||||
|
||||
try {
|
||||
$currentNextTokens = $tokens->tokens();
|
||||
} catch (Throwable $e) {
|
||||
if ($error === '') {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($languageStore->supportedLanguages() as $lang) {
|
||||
$headerLanguages[$lang] = $languageStore->languageLabel($lang);
|
||||
}
|
||||
|
||||
$styleVersion = @filemtime(__DIR__ . '/styles.css') ?: time();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= h($language) ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= h(t('app.configuration', 'Configuration')) ?></title>
|
||||
<link rel="stylesheet" href="/styles.css?v=<?= h($styleVersion) ?>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<?php
|
||||
render_app_header(array(
|
||||
'title' => t('app.configuration', 'Configuration'),
|
||||
'nav_items' => array(
|
||||
array('label' => t('app.back_to_sandbox', 'Back to Racket sandbox'), 'url' => '/?lang=' . rawurlencode($language)),
|
||||
array(
|
||||
'label' => t('app.manage_prompts', 'Manage prompts'),
|
||||
'url' => '/prompts?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
),
|
||||
array(
|
||||
'label' => t('app.user_management', 'User management'),
|
||||
'url' => '/users?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
),
|
||||
array(
|
||||
'label' => t('app.configuration', 'Configuration'),
|
||||
'url' => '/admin-config?lang=' . rawurlencode($language),
|
||||
'active' => true,
|
||||
'separator_before' => true,
|
||||
),
|
||||
),
|
||||
'user' => $currentUser,
|
||||
'user_prefix' => t('app.logged_in_as', 'Logged in as:'),
|
||||
'admin_label' => t('app.admin', 'Admin'),
|
||||
'language_label' => t('app.language', 'Language'),
|
||||
'language' => $language,
|
||||
'languages' => $headerLanguages,
|
||||
'language_action' => '/admin-config',
|
||||
'logout_action' => '/admin-config?lang=' . rawurlencode($language),
|
||||
'logout_label' => t('app.logout', 'Logout'),
|
||||
'message' => $message,
|
||||
'error' => $error,
|
||||
));
|
||||
?>
|
||||
|
||||
<main class="page-main dashboard-main">
|
||||
|
||||
<section class="panel">
|
||||
<h2><?= h(t('app.download_settings', 'Download settings')) ?></h2>
|
||||
|
||||
<form method="post" action="/admin-config?lang=<?= h($language) ?>" class="admin-form-grid">
|
||||
<input type="hidden" name="action" value="update_config">
|
||||
<label>
|
||||
<?= h(t('app.racket_zip_chunk_kb', 'Racket installation max base64 chunk size (KiB)')) ?><br>
|
||||
<input type="number" name="racket_zip_max_base64_kb" min="1" step="1" value="<?= h($base64ChunkConfig['racket_zip_max_base64_kb']) ?>" required>
|
||||
</label>
|
||||
<label>
|
||||
<?= h(t('app.package_zip_chunk_kb', 'Package/module max base64 chunk size (KiB)')) ?><br>
|
||||
<input type="number" name="package_zip_max_base64_kb" min="1" step="1" value="<?= h($base64ChunkConfig['package_zip_max_base64_kb']) ?>" required>
|
||||
</label>
|
||||
<button type="submit"><?= h(t('app.save_configuration', 'Save configuration')) ?></button>
|
||||
</form>
|
||||
|
||||
<p class="small"><?= h(t('app.chunk_size_hint_v2', 'Values are maximum base64 payload sizes in KiB. A 6144 KiB binary chunk becomes 8192 KiB base64. Racket installation parts are regenerated when this configuration is saved.')) ?></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th><?= h(t('app.racket_zip_chunk_kb', 'Racket installation max base64 chunk size (KiB)')) ?></th>
|
||||
<td><code><?= h($base64ChunkConfig['racket_zip_max_base64_kb']) ?></code> KiB</td>
|
||||
<td><?= h(t('app.effective_binary_chunk', 'Effective binary chunk')) ?>: <code><?= h(base64_binary_chunk_bytes_for_kib($base64ChunkConfig['racket_zip_max_base64_kb'])) ?></code> bytes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?= h(t('app.package_zip_chunk_kb', 'Package/module max base64 chunk size (KiB)')) ?></th>
|
||||
<td><code><?= h($base64ChunkConfig['package_zip_max_base64_kb']) ?></code> KiB</td>
|
||||
<td><?= h(t('app.effective_binary_chunk', 'Effective binary chunk')) ?>: <code><?= h(base64_binary_chunk_bytes_for_kib($base64ChunkConfig['package_zip_max_base64_kb'])) ?></code> bytes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?= h(t('app.racket_zip_source', 'Racket installation source')) ?></th>
|
||||
<td><code>config/racket.zip</code></td>
|
||||
<td><code><?= h(is_file(RACKET_ZIP_FILE) ? (string)filesize(RACKET_ZIP_FILE) : '0') ?></code> bytes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?= h(t('app.racket_parts', 'Racket installation parts')) ?></th>
|
||||
<td><code><?= h((string)($racketPartsManifest['part_count'] ?? 0)) ?></code></td>
|
||||
<td>
|
||||
<?php if ($racketPartsCurrent): ?>
|
||||
<?= h(t('app.racket_parts_current', 'current')) ?>,
|
||||
<?= h((string)($racketPartsManifest['created_at'] ?? '')) ?>
|
||||
<?php else: ?>
|
||||
<?= h(t('app.racket_parts_missing', 'missing or outdated; save configuration to regenerate')) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2><?= h(t('app.maintenance', 'Maintenance')) ?></h2>
|
||||
|
||||
<fieldset>
|
||||
<legend><?= h(t('app.next_tokens', 'Next tokens')) ?></legend>
|
||||
|
||||
<form method="post" action="/admin-config?lang=<?= h($language) ?>">
|
||||
<input type="hidden" name="action" value="cleanup_tokens">
|
||||
<button type="submit"><?= h(t('app.remove_expired_tokens', 'Remove expired next tokens')) ?></button>
|
||||
</form>
|
||||
|
||||
<p class="small">
|
||||
<?= h(t('app.cleanup_help', 'Expired links should return an outdated information message to the AI agent. Cleanup only removes old token rows from SQLite.')) ?>
|
||||
</p>
|
||||
|
||||
<h3><?= h(t('app.current_next_tokens', 'Current next tokens')) ?></h3>
|
||||
|
||||
<?php if (count($currentNextTokens) > 0): ?>
|
||||
<table>
|
||||
<tr>
|
||||
<th><?= h(t('app.token', 'Token')) ?></th>
|
||||
<th><?= h(t('app.created_at', 'Created at')) ?></th>
|
||||
<th><?= h(t('app.expires_at', 'Expires at')) ?></th>
|
||||
</tr>
|
||||
<?php foreach ($currentNextTokens as $nextToken): ?>
|
||||
<tr class="<?= h(token_status_class($nextToken)) ?>">
|
||||
<td><code><?= h($nextToken['token'] ?? '') ?></code></td>
|
||||
<td><?= h(format_token_time($nextToken['created_at'] ?? 0)) ?></td>
|
||||
<td><?= h(format_token_time($nextToken['expires_at'] ?? 0)) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<p class="small"><?= h(t('app.no_current_next_tokens', 'No current next tokens.')) ?></p>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
flfadrdeyc.yvtpmoyjm.gthfkqbrf.kyhhvikcv
|
||||
@@ -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,2 @@
|
||||
<?php
|
||||
require __DIR__ . '/../config.php';
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
Binary file not shown.
+578
@@ -0,0 +1,578 @@
|
||||
<?php
|
||||
/*
|
||||
* gitfetcher.php
|
||||
*
|
||||
* Doel:
|
||||
* Voor een gegeven package-naam en git repository:
|
||||
*
|
||||
* 1. bepaal de default branch;
|
||||
* 2. bepaal de huidige HEAD commit SHA;
|
||||
* 3. controleer data/<package>.json;
|
||||
* 4. als de SHA gelijk is en data/<package>.zip bestaat: niets downloaden;
|
||||
* 5. anders: haal de repository-zip op en sla op als data/<package>.zip.
|
||||
*
|
||||
* Ondersteund:
|
||||
* - github.com
|
||||
* - git.dijkewijk.nl Gitea
|
||||
* - codeberg.org Gitea
|
||||
*
|
||||
* GitHub:
|
||||
* - branch-SHA via /repos/{owner}/{repo}/branches/{branch}
|
||||
* - zip via /repos/{owner}/{repo}/zipball/{ref}
|
||||
*
|
||||
* Gitea/Codeberg:
|
||||
* - repository-info via /api/v1/repos/{owner}/{repo}
|
||||
* - branch-SHA via /api/v1/repos/{owner}/{repo}/branches/{branch}
|
||||
* - zip via /owner/repo/archive/{ref}.zip
|
||||
*/
|
||||
|
||||
class GitFetcherException extends Exception
|
||||
{
|
||||
}
|
||||
|
||||
class GitFetcher
|
||||
{
|
||||
private $dataDir;
|
||||
private $timeout;
|
||||
private $connectTimeout;
|
||||
private $userAgent;
|
||||
private $tokensByHost;
|
||||
|
||||
public function __construct($options = array())
|
||||
{
|
||||
$this->dataDir = isset($options['data_dir'])
|
||||
? rtrim((string)$options['data_dir'], '/')
|
||||
: __DIR__ . '/data';
|
||||
|
||||
$this->timeout = isset($options['timeout']) ? (int)$options['timeout'] : 180;
|
||||
$this->connectTimeout = isset($options['connect_timeout']) ? (int)$options['connect_timeout'] : 20;
|
||||
$this->userAgent = isset($options['user_agent'])
|
||||
? (string)$options['user_agent']
|
||||
: 'rktsndbx-gitfetcher/1.0';
|
||||
|
||||
$this->tokensByHost = isset($options['tokens']) && is_array($options['tokens'])
|
||||
? $options['tokens']
|
||||
: array();
|
||||
}
|
||||
|
||||
/*
|
||||
* Hoofdentry voor jouw package-route.
|
||||
*
|
||||
* Voorbeeld:
|
||||
*
|
||||
* $gf = new GitFetcher();
|
||||
* $info = $gf->ensurePackageZip(
|
||||
* 'html-parsing',
|
||||
* 'https://github.com/soegaard/html-parsing'
|
||||
* );
|
||||
*
|
||||
* Resultaat:
|
||||
*
|
||||
* data/html-parsing.zip
|
||||
* data/html-parsing.json
|
||||
*/
|
||||
public function ensurePackageZip($packageName, $repoUrl)
|
||||
{
|
||||
$packageName = $this->safePackageName($packageName);
|
||||
$repo = $this->parseRepositoryUrl($repoUrl);
|
||||
$head = $this->currentHead($repo);
|
||||
|
||||
$zipFile = $this->packageZipFile($packageName);
|
||||
$metaFile = $this->packageMetaFile($packageName);
|
||||
|
||||
$old = $this->readJsonFile($metaFile);
|
||||
|
||||
if (is_file($zipFile) &&
|
||||
is_readable($zipFile) &&
|
||||
is_array($old) &&
|
||||
isset($old['head_sha']) &&
|
||||
$old['head_sha'] === $head['head_sha'] &&
|
||||
isset($old['repo_url']) &&
|
||||
$old['repo_url'] === $repoUrl) {
|
||||
|
||||
return array(
|
||||
'status' => 'cached',
|
||||
'package' => $packageName,
|
||||
'repo_url' => $repoUrl,
|
||||
'host' => $repo['host'],
|
||||
'owner' => $repo['owner'],
|
||||
'repo' => $repo['repo'],
|
||||
'default_branch' => $head['default_branch'],
|
||||
'head_sha' => $head['head_sha'],
|
||||
'zip_file' => $zipFile,
|
||||
'meta_file' => $metaFile,
|
||||
'zip_bytes' => filesize($zipFile),
|
||||
'zip_sha256' => hash_file('sha256', $zipFile),
|
||||
);
|
||||
}
|
||||
|
||||
$archive = $this->downloadArchiveForHead($repo, $head);
|
||||
$this->ensureDataDir();
|
||||
|
||||
$tmpZip = $zipFile . '.tmp.' . getmypid();
|
||||
$tmpMeta = $metaFile . '.tmp.' . getmypid();
|
||||
|
||||
if (file_put_contents($tmpZip, $archive['bytes'], LOCK_EX) === false) {
|
||||
@unlink($tmpZip);
|
||||
throw new GitFetcherException('Kan tijdelijke zip niet schrijven: ' . $tmpZip);
|
||||
}
|
||||
|
||||
$meta = array(
|
||||
'package' => $packageName,
|
||||
'repo_url' => $repoUrl,
|
||||
'host' => $repo['host'],
|
||||
'owner' => $repo['owner'],
|
||||
'repo' => $repo['repo'],
|
||||
'default_branch' => $head['default_branch'],
|
||||
'head_sha' => $head['head_sha'],
|
||||
'archive_url' => $archive['archive_url'],
|
||||
'zip_file' => $zipFile,
|
||||
'zip_bytes' => strlen($archive['bytes']),
|
||||
'zip_sha256' => hash('sha256', $archive['bytes']),
|
||||
'updated_at' => gmdate('c'),
|
||||
);
|
||||
|
||||
$json = json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false || file_put_contents($tmpMeta, $json . "\n", LOCK_EX) === false) {
|
||||
@unlink($tmpZip);
|
||||
@unlink($tmpMeta);
|
||||
throw new GitFetcherException('Kan tijdelijke metadata niet schrijven: ' . $tmpMeta);
|
||||
}
|
||||
|
||||
if (!rename($tmpZip, $zipFile)) {
|
||||
@unlink($tmpZip);
|
||||
@unlink($tmpMeta);
|
||||
throw new GitFetcherException('Kan zip niet plaatsen: ' . $zipFile);
|
||||
}
|
||||
|
||||
if (!rename($tmpMeta, $metaFile)) {
|
||||
@unlink($tmpMeta);
|
||||
throw new GitFetcherException('Kan metadata niet plaatsen: ' . $metaFile);
|
||||
}
|
||||
|
||||
$meta['status'] = 'downloaded';
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/*
|
||||
* Alleen controleren, zonder zip te downloaden.
|
||||
*/
|
||||
public function packageZipIsCurrent($packageName, $repoUrl)
|
||||
{
|
||||
$packageName = $this->safePackageName($packageName);
|
||||
$repo = $this->parseRepositoryUrl($repoUrl);
|
||||
$head = $this->currentHead($repo);
|
||||
|
||||
$zipFile = $this->packageZipFile($packageName);
|
||||
$metaFile = $this->packageMetaFile($packageName);
|
||||
$old = $this->readJsonFile($metaFile);
|
||||
|
||||
return is_file($zipFile) &&
|
||||
is_readable($zipFile) &&
|
||||
is_array($old) &&
|
||||
isset($old['repo_url']) &&
|
||||
$old['repo_url'] === $repoUrl &&
|
||||
isset($old['head_sha']) &&
|
||||
$old['head_sha'] === $head['head_sha'];
|
||||
}
|
||||
|
||||
/*
|
||||
* Bepaal default branch + huidige commit SHA.
|
||||
*/
|
||||
public function currentHeadForRepositoryUrl($repoUrl)
|
||||
{
|
||||
$repo = $this->parseRepositoryUrl($repoUrl);
|
||||
return $this->currentHead($repo);
|
||||
}
|
||||
|
||||
private function safePackageName($packageName)
|
||||
{
|
||||
$packageName = (string)$packageName;
|
||||
|
||||
if (!preg_match('/^[A-Za-z0-9_.+-]+$/', $packageName)) {
|
||||
throw new GitFetcherException('Ongeldige package naam: ' . $packageName);
|
||||
}
|
||||
|
||||
return $packageName;
|
||||
}
|
||||
|
||||
private function packageZipFile($packageName)
|
||||
{
|
||||
return $this->dataDir . '/' . $packageName . '.zip';
|
||||
}
|
||||
|
||||
private function packageMetaFile($packageName)
|
||||
{
|
||||
return $this->dataDir . '/' . $packageName . '.json';
|
||||
}
|
||||
|
||||
private function ensureDataDir()
|
||||
{
|
||||
if (!is_dir($this->dataDir)) {
|
||||
if (!mkdir($this->dataDir, 0755, true)) {
|
||||
throw new GitFetcherException('Kan data directory niet maken: ' . $this->dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($this->dataDir)) {
|
||||
throw new GitFetcherException('Data directory is niet schrijfbaar: ' . $this->dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function readJsonFile($file)
|
||||
{
|
||||
if (!is_file($file) || !is_readable($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($file);
|
||||
|
||||
if ($raw === false || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
public function parseRepositoryUrl($repoUrl)
|
||||
{
|
||||
$repoUrl = trim((string)$repoUrl);
|
||||
|
||||
/*
|
||||
* SSH-vorm:
|
||||
* git@github.com:owner/repo.git
|
||||
*/
|
||||
if (preg_match('/^git@([^:]+):(.+)$/', $repoUrl, $m)) {
|
||||
return $this->parseHostAndPath(strtolower($m[1]), trim($m[2], '/'), $repoUrl);
|
||||
}
|
||||
|
||||
if (strpos($repoUrl, 'git+https://') === 0) {
|
||||
$repoUrl = 'https://' . substr($repoUrl, strlen('git+https://'));
|
||||
} elseif (strpos($repoUrl, 'git+http://') === 0) {
|
||||
$repoUrl = 'http://' . substr($repoUrl, strlen('git+http://'));
|
||||
}
|
||||
|
||||
$p = parse_url($repoUrl);
|
||||
|
||||
if ($p === false || empty($p['host']) || empty($p['path'])) {
|
||||
throw new GitFetcherException('Ongeldige repository URL: ' . $repoUrl);
|
||||
}
|
||||
|
||||
return $this->parseHostAndPath(
|
||||
strtolower($p['host']),
|
||||
trim($p['path'], '/'),
|
||||
$repoUrl
|
||||
);
|
||||
}
|
||||
|
||||
private function parseHostAndPath($host, $path, $originalUrl)
|
||||
{
|
||||
$kinds = array(
|
||||
'github.com' => 'github',
|
||||
'git.dijkewijk.nl' => 'gitea',
|
||||
'codeberg.org' => 'gitea',
|
||||
);
|
||||
|
||||
if (!isset($kinds[$host])) {
|
||||
throw new GitFetcherException('Niet-ondersteunde git host: ' . $host);
|
||||
}
|
||||
|
||||
if (substr($path, -4) === '.git') {
|
||||
$path = substr($path, 0, -4);
|
||||
}
|
||||
|
||||
$bits = explode('/', $path);
|
||||
|
||||
if (count($bits) < 2 || $bits[0] === '' || $bits[1] === '') {
|
||||
throw new GitFetcherException('Kan owner/repo niet bepalen uit URL: ' . $originalUrl);
|
||||
}
|
||||
|
||||
return array(
|
||||
'kind' => $kinds[$host],
|
||||
'host' => $host,
|
||||
'owner' => $bits[0],
|
||||
'repo' => $bits[1],
|
||||
);
|
||||
}
|
||||
|
||||
private function currentHead($repo)
|
||||
{
|
||||
if ($repo['kind'] === 'github') {
|
||||
return $this->githubCurrentHead($repo);
|
||||
}
|
||||
|
||||
return $this->giteaCurrentHead($repo);
|
||||
}
|
||||
|
||||
private function githubCurrentHead($repo)
|
||||
{
|
||||
$repoApi =
|
||||
'https://api.github.com/repos/' .
|
||||
rawurlencode($repo['owner']) . '/' .
|
||||
rawurlencode($repo['repo']);
|
||||
|
||||
$repoJson = $this->httpGetJson($repoApi, $repo['host']);
|
||||
|
||||
if (empty($repoJson['default_branch']) || !is_string($repoJson['default_branch'])) {
|
||||
throw new GitFetcherException('GitHub API gaf geen default_branch.');
|
||||
}
|
||||
|
||||
$branch = $repoJson['default_branch'];
|
||||
|
||||
$branchApi =
|
||||
'https://api.github.com/repos/' .
|
||||
rawurlencode($repo['owner']) . '/' .
|
||||
rawurlencode($repo['repo']) .
|
||||
'/branches/' .
|
||||
rawurlencode($branch);
|
||||
|
||||
$branchJson = $this->httpGetJson($branchApi, $repo['host']);
|
||||
|
||||
if (empty($branchJson['commit']['sha']) || !is_string($branchJson['commit']['sha'])) {
|
||||
throw new GitFetcherException('GitHub API gaf geen branch commit SHA.');
|
||||
}
|
||||
|
||||
return array(
|
||||
'default_branch' => $branch,
|
||||
'head_sha' => $branchJson['commit']['sha'],
|
||||
);
|
||||
}
|
||||
|
||||
private function giteaCurrentHead($repo)
|
||||
{
|
||||
$repoApi =
|
||||
'https://' . $repo['host'] .
|
||||
'/api/v1/repos/' .
|
||||
rawurlencode($repo['owner']) . '/' .
|
||||
rawurlencode($repo['repo']);
|
||||
|
||||
$repoJson = $this->httpGetJson($repoApi, $repo['host']);
|
||||
|
||||
$branch = null;
|
||||
|
||||
if (!empty($repoJson['default_branch']) && is_string($repoJson['default_branch'])) {
|
||||
$branch = $repoJson['default_branch'];
|
||||
} elseif (!empty($repoJson['default_branch_name']) && is_string($repoJson['default_branch_name'])) {
|
||||
$branch = $repoJson['default_branch_name'];
|
||||
}
|
||||
|
||||
if ($branch === null || $branch === '') {
|
||||
$branch = 'main';
|
||||
}
|
||||
|
||||
$branchApi =
|
||||
'https://' . $repo['host'] .
|
||||
'/api/v1/repos/' .
|
||||
rawurlencode($repo['owner']) . '/' .
|
||||
rawurlencode($repo['repo']) .
|
||||
'/branches/' .
|
||||
rawurlencode($branch);
|
||||
|
||||
$branchJson = $this->httpGetJson($branchApi, $repo['host']);
|
||||
$sha = $this->extractGiteaBranchSha($branchJson);
|
||||
|
||||
if ($sha === null || $sha === '') {
|
||||
throw new GitFetcherException('Gitea API gaf geen branch commit SHA.');
|
||||
}
|
||||
|
||||
return array(
|
||||
'default_branch' => $branch,
|
||||
'head_sha' => $sha,
|
||||
);
|
||||
}
|
||||
|
||||
private function extractGiteaBranchSha($branchJson)
|
||||
{
|
||||
/*
|
||||
* Gitea/Forgejo varianten komen in de praktijk voor als:
|
||||
* commit.id
|
||||
* commit.sha
|
||||
* commit.commit.id
|
||||
*/
|
||||
$paths = array(
|
||||
array('commit', 'id'),
|
||||
array('commit', 'sha'),
|
||||
array('commit', 'commit', 'id'),
|
||||
);
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$v = $branchJson;
|
||||
|
||||
foreach ($path as $k) {
|
||||
if (!is_array($v) || !array_key_exists($k, $v)) {
|
||||
$v = null;
|
||||
break;
|
||||
}
|
||||
|
||||
$v = $v[$k];
|
||||
}
|
||||
|
||||
if (is_string($v) && preg_match('/^[0-9a-f]{7,40}$/i', $v)) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function downloadArchiveForHead($repo, $head)
|
||||
{
|
||||
/*
|
||||
* Bij voorkeur downloaden we op exacte SHA, niet op branchnaam.
|
||||
* Dan hoort de zip exact bij de SHA die we in metadata opslaan.
|
||||
*/
|
||||
$shaUrl = $this->archiveUrl($repo, $head['head_sha']);
|
||||
$branchUrl = $this->archiveUrl($repo, $head['default_branch']);
|
||||
|
||||
try {
|
||||
$bytes = $this->httpGet($shaUrl, $repo['host'], true);
|
||||
return array(
|
||||
'archive_url' => $shaUrl,
|
||||
'bytes' => $bytes,
|
||||
);
|
||||
} catch (GitFetcherException $e) {
|
||||
/*
|
||||
* Sommige Gitea/Forgejo instanties accepteren branch/tag ref wel
|
||||
* maar commit-SHA niet in archive/<ref>.zip. Dan fallback naar
|
||||
* branch. De SHA-check blijft alsnog gebaseerd op de API.
|
||||
*/
|
||||
$bytes = $this->httpGet($branchUrl, $repo['host'], true);
|
||||
return array(
|
||||
'archive_url' => $branchUrl,
|
||||
'bytes' => $bytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function archiveUrl($repo, $ref)
|
||||
{
|
||||
if ($ref === '') {
|
||||
throw new GitFetcherException('Lege archive ref.');
|
||||
}
|
||||
|
||||
if ($repo['kind'] === 'github') {
|
||||
return
|
||||
'https://api.github.com/repos/' .
|
||||
rawurlencode($repo['owner']) . '/' .
|
||||
rawurlencode($repo['repo']) .
|
||||
'/zipball/' .
|
||||
rawurlencode($ref);
|
||||
}
|
||||
|
||||
return
|
||||
'https://' . $repo['host'] . '/' .
|
||||
rawurlencode($repo['owner']) . '/' .
|
||||
rawurlencode($repo['repo']) .
|
||||
'/archive/' .
|
||||
rawurlencode($ref) .
|
||||
'.zip';
|
||||
}
|
||||
|
||||
private function httpGetJson($url, $host)
|
||||
{
|
||||
$body = $this->httpGet($url, $host, true);
|
||||
$json = json_decode($body, true);
|
||||
|
||||
if (!is_array($json)) {
|
||||
throw new GitFetcherException('Response is geen JSON: ' . $url);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function httpGet($url, $host, $followRedirects)
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return $this->httpGetWithoutCurl($url, $host);
|
||||
}
|
||||
|
||||
$headers = array(
|
||||
'User-Agent: ' . $this->userAgent,
|
||||
);
|
||||
|
||||
if ($host === 'github.com') {
|
||||
$headers[] = 'Accept: application/vnd.github+json';
|
||||
$headers[] = 'X-GitHub-Api-Version: 2022-11-28';
|
||||
}
|
||||
|
||||
if (!empty($this->tokensByHost[$host])) {
|
||||
if ($host === 'github.com') {
|
||||
$headers[] = 'Authorization: Bearer ' . $this->tokensByHost[$host];
|
||||
} else {
|
||||
$headers[] = 'Authorization: token ' . $this->tokensByHost[$host];
|
||||
}
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
|
||||
curl_setopt_array($ch, array(
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => $followRedirects ? true : false,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_USERAGENT => $this->userAgent,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
));
|
||||
|
||||
$body = curl_exec($ch);
|
||||
|
||||
if ($body === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new GitFetcherException('HTTP request mislukt: ' . $err . ' url=' . $url);
|
||||
}
|
||||
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
throw new GitFetcherException(
|
||||
'HTTP status ' . $status . ' voor ' . $url . "\n" .
|
||||
substr((string)$body, 0, 500)
|
||||
);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function httpGetWithoutCurl($url, $host)
|
||||
{
|
||||
$headers = array(
|
||||
'User-Agent: ' . $this->userAgent,
|
||||
);
|
||||
|
||||
if ($host === 'github.com') {
|
||||
$headers[] = 'Accept: application/vnd.github+json';
|
||||
$headers[] = 'X-GitHub-Api-Version: 2022-11-28';
|
||||
}
|
||||
|
||||
if (!empty($this->tokensByHost[$host])) {
|
||||
if ($host === 'github.com') {
|
||||
$headers[] = 'Authorization: Bearer ' . $this->tokensByHost[$host];
|
||||
} else {
|
||||
$headers[] = 'Authorization: token ' . $this->tokensByHost[$host];
|
||||
}
|
||||
}
|
||||
|
||||
$ctx = stream_context_create(array(
|
||||
'http' => array(
|
||||
'method' => 'GET',
|
||||
'timeout' => $this->timeout,
|
||||
'ignore_errors' => true,
|
||||
'header' => implode("\r\n", $headers) . "\r\n",
|
||||
),
|
||||
));
|
||||
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
|
||||
if ($body === false) {
|
||||
throw new GitFetcherException('HTTP request mislukt: ' . $url);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
+94
@@ -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,470 @@
|
||||
<?php
|
||||
/*
|
||||
* index.php
|
||||
*
|
||||
* Menselijk startpunt voor Racket sandbox.
|
||||
*
|
||||
* Niet ingelogd:
|
||||
* - verwijst naar login
|
||||
*
|
||||
* Ingelogd:
|
||||
* - bootstrap-link genereren
|
||||
* - logout
|
||||
*
|
||||
* Admin:
|
||||
* - gebruikersoverzicht
|
||||
* - admin/enabled vlaggen wijzigen
|
||||
* - wachtwoord wijzigen
|
||||
* - gebruiker verwijderen
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/header.php';
|
||||
require_once __DIR__ . '/languagestore.php';
|
||||
require_once __DIR__ . '/nexttoken.php';
|
||||
require_once __DIR__ . '/promptstore.php';
|
||||
require_once __DIR__ . '/usersettings.php';
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
|
||||
|
||||
$auth = new RacketSandboxAuth($DB_FILE);
|
||||
$languageStore = new LanguageStore($DB_FILE);
|
||||
$tokens = new NextTokenStore($DB_FILE);
|
||||
$promptStore = new PromptStore($DB_FILE);
|
||||
$userSettings = new UserSettingsStore($DB_FILE);
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
$issuedLink = '';
|
||||
$bootstrapPrompts = array();
|
||||
|
||||
define('BOOTSTRAP_TTL_DEFAULT_MINUTES', 120);
|
||||
define('BOOTSTRAP_TTL_MIN_MINUTES', 30);
|
||||
define('BOOTSTRAP_TTL_MAX_MINUTES', 8 * 60);
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function get_value($name, $default = '')
|
||||
{
|
||||
return $_GET[$name] ?? $default;
|
||||
}
|
||||
|
||||
function t($key, $fallback = null)
|
||||
{
|
||||
global $languageStore, $language;
|
||||
|
||||
return $languageStore->translate($key, $language, $fallback);
|
||||
}
|
||||
|
||||
function current_scheme()
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||
return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0]));
|
||||
}
|
||||
|
||||
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
}
|
||||
|
||||
function current_host()
|
||||
{
|
||||
return $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
}
|
||||
|
||||
function bootstrap_link_for_token($token)
|
||||
{
|
||||
return current_scheme() . '://' . current_host() .
|
||||
'/bootstrap-racket?next=' . rawurlencode($token);
|
||||
}
|
||||
|
||||
function post_value($name, $default = '')
|
||||
{
|
||||
return $_POST[$name] ?? $default;
|
||||
}
|
||||
|
||||
function post_bool($name)
|
||||
{
|
||||
return isset($_POST[$name]) && $_POST[$name] === '1';
|
||||
}
|
||||
|
||||
function create_next_token($tokens, $ttlSeconds, $meta)
|
||||
{
|
||||
/*
|
||||
* Ondersteunt zowel:
|
||||
* create($ttl)
|
||||
* als:
|
||||
* create($ttl, $meta)
|
||||
*/
|
||||
$rm = new ReflectionMethod($tokens, 'create');
|
||||
|
||||
if ($rm->getNumberOfParameters() >= 2) {
|
||||
return $tokens->create($ttlSeconds, $meta);
|
||||
}
|
||||
|
||||
return $tokens->create($ttlSeconds);
|
||||
}
|
||||
|
||||
function clamp_bootstrap_ttl_minutes($ttlMinutes)
|
||||
{
|
||||
$ttlMinutes = (int)$ttlMinutes;
|
||||
|
||||
if ($ttlMinutes < BOOTSTRAP_TTL_MIN_MINUTES) {
|
||||
return BOOTSTRAP_TTL_MIN_MINUTES;
|
||||
}
|
||||
|
||||
if ($ttlMinutes > BOOTSTRAP_TTL_MAX_MINUTES) {
|
||||
return BOOTSTRAP_TTL_MAX_MINUTES;
|
||||
}
|
||||
|
||||
return $ttlMinutes;
|
||||
}
|
||||
|
||||
function resolve_user_language($userSettings, $userId, $allowedLanguages)
|
||||
{
|
||||
$language = isset($_GET['lang'])
|
||||
? (string)$_GET['lang']
|
||||
: (string)$userSettings->get($userId, 'language', 'en');
|
||||
|
||||
if (!in_array($language, $allowedLanguages, true)) {
|
||||
$language = 'en';
|
||||
}
|
||||
|
||||
$userSettings->set($userId, 'language', $language);
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
$currentUser = $auth->currentUser();
|
||||
|
||||
if ($currentUser === null) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$language = resolve_user_language(
|
||||
$userSettings,
|
||||
$currentUser->id(),
|
||||
$languageStore->supportedLanguages()
|
||||
);
|
||||
|
||||
$languageStore->seedDefaults(array(
|
||||
'app.title' => array('en' => 'Racket sandbox', 'nl' => 'Racket sandbox'),
|
||||
'app.manage_prompts' => array('en' => 'Manage prompts', 'nl' => 'Prompts beheren'),
|
||||
'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'),
|
||||
'app.language' => array('en' => 'Language', 'nl' => 'Taal'),
|
||||
'app.logged_in_as' => array('en' => 'Logged in as:', 'nl' => 'Ingelogd als:'),
|
||||
'app.admin' => array('en' => 'admin', 'nl' => 'admin'),
|
||||
'app.bootstrap_link' => array('en' => 'Bootstrap link', 'nl' => 'Bootstraplink'),
|
||||
'app.generate_bootstrap_link' => array('en' => 'Generate bootstrap link', 'nl' => 'Bootstraplink genereren'),
|
||||
'app.ttl_minutes' => array('en' => 'TTL in minutes', 'nl' => 'TTL in minuten'),
|
||||
'app.ttl_range_help' => array('en' => 'Allowed range: 30 minutes to 8 hours.', 'nl' => 'Toegestaan bereik: 30 minuten tot 8 uur.'),
|
||||
'app.generated_link' => array('en' => 'Generated link', 'nl' => 'Gegenereerde link'),
|
||||
'app.copy' => array('en' => 'Copy', 'nl' => 'Kopieer'),
|
||||
'app.copied' => array('en' => 'Copied', 'nl' => 'Gekopieerd'),
|
||||
'app.generated_link_help' => array(
|
||||
'en' => 'Give this link to the AI agent. The agent should start from this link and then only follow links from the generated HTML pages.',
|
||||
'nl' => 'Geef deze link aan de AI-agent. De agent moet vanaf deze link starten en daarna alleen links volgen vanuit de gegenereerde HTML-paginas.'
|
||||
),
|
||||
'app.select_prompt' => array('en' => 'Select prompt', 'nl' => 'Prompt selecteren'),
|
||||
'app.copy_full_prompt' => array('en' => 'Copy full prompt', 'nl' => 'Volledige prompt kopieren'),
|
||||
'app.bootstrap_prompt_help' => array(
|
||||
'en' => 'Choose one of your prompts. The placeholder {{bootstrap-racket-link}} is replaced by the generated bootstrap link.',
|
||||
'nl' => 'Kies een van je prompts. De placeholder {{bootstrap-racket-link}} wordt vervangen door de gegenereerde bootstraplink.'
|
||||
),
|
||||
'app.no_bootstrap_prompts' => array(
|
||||
'en' => 'No personal prompts are available for this language. Copy a default prompt first from prompt management.',
|
||||
'nl' => 'Er zijn geen persoonlijke prompts beschikbaar voor deze taal. Kopieer eerst een standaardprompt vanuit promptbeheer.'
|
||||
),
|
||||
'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'),
|
||||
'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'),
|
||||
'app.user_management_help' => array(
|
||||
'en' => 'Users are registered manually by email address, full name and password. This page only manages existing users.',
|
||||
'nl' => 'Gebruikers worden handmatig geregistreerd met e-mailadres, volledige naam en wachtwoord. Deze pagina beheert alleen bestaande gebruikers.'
|
||||
),
|
||||
'app.id' => array('en' => 'ID', 'nl' => 'ID'),
|
||||
'app.full_name' => array('en' => 'Full name', 'nl' => 'Volledige naam'),
|
||||
'app.email' => array('en' => 'Email', 'nl' => 'E-mail'),
|
||||
'app.enabled' => array('en' => 'Enabled', 'nl' => 'Ingeschakeld'),
|
||||
'app.created' => array('en' => 'Created', 'nl' => 'Gemaakt'),
|
||||
'app.last_login' => array('en' => 'Last login', 'nl' => 'Laatste login'),
|
||||
'app.actions' => array('en' => 'Actions', 'nl' => 'Acties'),
|
||||
'app.yes' => array('en' => 'yes', 'nl' => 'ja'),
|
||||
'app.no' => array('en' => 'no', 'nl' => 'nee'),
|
||||
'app.save_flags' => array('en' => 'Save flags', 'nl' => 'Vlaggen opslaan'),
|
||||
'app.new_password' => array('en' => 'New password', 'nl' => 'Nieuw wachtwoord'),
|
||||
'app.change_password' => array('en' => 'Change password', 'nl' => 'Wachtwoord wijzigen'),
|
||||
'app.delete_user' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'),
|
||||
'app.delete_user_confirm' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'),
|
||||
'app.cannot_delete_self' => array('en' => 'You cannot delete your own account.', 'nl' => 'Je kunt je eigen account niet verwijderen.'),
|
||||
'app.bootstrap_link_issued' => array('en' => 'Bootstrap link issued.', 'nl' => 'Bootstraplink aangemaakt.'),
|
||||
'app.password_changed_for' => array('en' => 'Password changed for:', 'nl' => 'Wachtwoord gewijzigd voor:'),
|
||||
'app.user_flags_updated' => array('en' => 'User flags updated.', 'nl' => 'Gebruikersvlaggen bijgewerkt.'),
|
||||
'app.user_deleted' => array('en' => 'User deleted.', 'nl' => 'Gebruiker verwijderd.'),
|
||||
'app.admin_rights_required' => array('en' => 'Admin rights required.', 'nl' => 'Adminrechten vereist.'),
|
||||
'app.cannot_remove_own_admin' => array('en' => 'You cannot remove your own admin rights.', 'nl' => 'Je kunt je eigen adminrechten niet verwijderen.'),
|
||||
'app.cannot_disable_self' => array('en' => 'You cannot disable your own account.', 'nl' => 'Je kunt je eigen account niet uitschakelen.'),
|
||||
'app.unknown_action' => array('en' => 'Unknown action:', 'nl' => 'Onbekende actie:'),
|
||||
));
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = post_value('action');
|
||||
|
||||
try {
|
||||
if ($action === 'logout') {
|
||||
$auth->logout();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUser = $auth->currentUser();
|
||||
|
||||
if ($currentUser === null) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'issue_bootstrap') {
|
||||
$ttlMinutes = clamp_bootstrap_ttl_minutes(
|
||||
post_value('ttl_minutes', (string)BOOTSTRAP_TTL_DEFAULT_MINUTES)
|
||||
);
|
||||
|
||||
$userSettings->set($currentUser->id(), 'bootstrap_ttl_minutes', (string)$ttlMinutes);
|
||||
|
||||
$token = create_next_token(
|
||||
$tokens,
|
||||
$ttlMinutes * 60,
|
||||
array(
|
||||
'user_id' => $currentUser->id(),
|
||||
'email' => $currentUser->email(),
|
||||
'full_name' => $currentUser->fullName(),
|
||||
'purpose' => 'racket-bootstrap',
|
||||
)
|
||||
);
|
||||
|
||||
$issuedLink = bootstrap_link_for_token($token);
|
||||
$message = t('app.bootstrap_link_issued', 'Bootstrap link issued.');
|
||||
} else {
|
||||
if (!$currentUser->isAdmin()) {
|
||||
throw new Exception(t('app.admin_rights_required', 'Admin rights required.'));
|
||||
}
|
||||
|
||||
if ($action === 'set_password') {
|
||||
$email = trim(post_value('email'));
|
||||
$password = (string)post_value('password');
|
||||
|
||||
$auth->setPassword($email, $password);
|
||||
$message = t('app.password_changed_for', 'Password changed for:') . ' ' . $email;
|
||||
} elseif ($action === 'set_flags') {
|
||||
$userId = (int)post_value('user_id');
|
||||
$isAdmin = post_bool('is_admin');
|
||||
$isEnabled = post_bool('is_enabled');
|
||||
|
||||
if ($userId === $currentUser->id() && !$isAdmin) {
|
||||
throw new Exception(t('app.cannot_remove_own_admin', 'You cannot remove your own admin rights.'));
|
||||
}
|
||||
|
||||
if ($userId === $currentUser->id() && !$isEnabled) {
|
||||
throw new Exception(t('app.cannot_disable_self', 'You cannot disable your own account.'));
|
||||
}
|
||||
|
||||
$auth->setAdmin($userId, $isAdmin);
|
||||
$auth->setEnabled($userId, $isEnabled);
|
||||
$message = t('app.user_flags_updated', 'User flags updated.');
|
||||
} elseif ($action === 'delete_user') {
|
||||
$userId = (int)post_value('user_id');
|
||||
|
||||
if ($userId === $currentUser->id()) {
|
||||
throw new Exception('You cannot delete your own account.');
|
||||
}
|
||||
|
||||
$auth->deleteUser($userId);
|
||||
$message = t('app.user_deleted', 'User deleted.');
|
||||
} elseif ($action !== '') {
|
||||
throw new Exception(t('app.unknown_action', 'Unknown action:') . ' ' . $action);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$currentUser = $auth->currentUser();
|
||||
|
||||
if ($currentUser === null) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$bootstrapTtlMinutes = clamp_bootstrap_ttl_minutes(
|
||||
$userSettings->get(
|
||||
$currentUser->id(),
|
||||
'bootstrap_ttl_minutes',
|
||||
(string)BOOTSTRAP_TTL_DEFAULT_MINUTES
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($promptStore->listPrompts($currentUser->id(), $language) as $prompt) {
|
||||
$bootstrapPrompts[] = array(
|
||||
'id' => $prompt->id(),
|
||||
'name' => $prompt->name(),
|
||||
'content' => $prompt->content(),
|
||||
);
|
||||
}
|
||||
|
||||
$headerLanguages = array();
|
||||
|
||||
foreach ($languageStore->supportedLanguages() as $lang) {
|
||||
$headerLanguages[$lang] = $languageStore->languageLabel($lang);
|
||||
}
|
||||
|
||||
$headerNavItems = array(
|
||||
array(
|
||||
'label' => t('app.manage_prompts', 'Manage prompts'),
|
||||
'url' => '/prompts?lang=' . rawurlencode($language),
|
||||
),
|
||||
);
|
||||
|
||||
if ($currentUser->isAdmin()) {
|
||||
$headerNavItems[] = array(
|
||||
'label' => t('app.user_management', 'User management'),
|
||||
'url' => '/users?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
);
|
||||
$headerNavItems[] = array(
|
||||
'label' => t('app.configuration', 'Configuration'),
|
||||
'url' => '/admin-config?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
);
|
||||
}
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= h($language) ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= h(t('app.title', 'Racket sandbox')) ?></title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<?php
|
||||
render_app_header(array(
|
||||
'title' => t('app.title', 'Racket sandbox'),
|
||||
'nav_items' => $headerNavItems,
|
||||
'user' => $currentUser,
|
||||
'user_prefix' => t('app.logged_in_as', 'Logged in as:'),
|
||||
'admin_label' => t('app.admin', 'admin'),
|
||||
'language_label' => t('app.language', 'Language'),
|
||||
'language' => $language,
|
||||
'languages' => $headerLanguages,
|
||||
'language_action' => '/',
|
||||
'logout_action' => '/?lang=' . rawurlencode($language),
|
||||
'logout_label' => t('app.logout', 'Logout'),
|
||||
'message' => $message,
|
||||
'error' => $error,
|
||||
));
|
||||
?>
|
||||
|
||||
<main class="page-main dashboard-main">
|
||||
|
||||
<section class="panel">
|
||||
<h2><?= h(t('app.bootstrap_link', 'Bootstrap link')) ?></h2>
|
||||
|
||||
<fieldset>
|
||||
<legend><?= h(t('app.generate_bootstrap_link', 'Generate bootstrap link')) ?></legend>
|
||||
|
||||
<div class="bootstrap-result-grid <?= $issuedLink !== '' ? 'has-prompt' : '' ?>">
|
||||
<div>
|
||||
<form method="post" action="/?lang=<?= h($language) ?>">
|
||||
<input type="hidden" name="action" value="issue_bootstrap">
|
||||
|
||||
<label>
|
||||
<?= h(t('app.ttl_minutes', 'TTL in minutes')) ?><br>
|
||||
<input type="number"
|
||||
name="ttl_minutes"
|
||||
value="<?= h($bootstrapTtlMinutes) ?>"
|
||||
min="<?= h(BOOTSTRAP_TTL_MIN_MINUTES) ?>"
|
||||
max="<?= h(BOOTSTRAP_TTL_MAX_MINUTES) ?>">
|
||||
</label>
|
||||
<p class="small"><?= h(t('app.ttl_range_help', 'Allowed range: 30 minutes to 8 hours.')) ?></p>
|
||||
|
||||
<button type="submit"><?= h(t('app.generate_bootstrap_link', 'Generate bootstrap link')) ?></button>
|
||||
</form>
|
||||
|
||||
<?php if ($issuedLink !== ''): ?>
|
||||
<h3><?= h(t('app.generated_link', 'Generated link')) ?></h3>
|
||||
<div class="generated-link-row">
|
||||
<a href="<?= h($issuedLink) ?>" target="_blank" rel="noopener noreferrer"><?= h($issuedLink) ?></a>
|
||||
<button type="button"
|
||||
class="js-copy-button"
|
||||
data-copy-text="<?= h($issuedLink) ?>"
|
||||
data-copy-label="<?= h(t('app.copy', 'Copy')) ?>"
|
||||
data-copied-label="<?= h(t('app.copied', 'Copied')) ?>">
|
||||
<?= h(t('app.copy', 'Copy')) ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($issuedLink !== ''): ?>
|
||||
<div>
|
||||
<h3><?= h(t('app.copy_full_prompt', 'Copy full prompt')) ?></h3>
|
||||
<p>
|
||||
<?= h(t('app.bootstrap_prompt_help', 'Choose one of your prompts. The placeholder {{bootstrap-racket-link}} is replaced by the generated bootstrap link.')) ?>
|
||||
</p>
|
||||
|
||||
<?php if (count($bootstrapPrompts) > 0): ?>
|
||||
<div class="bootstrap-prompt-tool">
|
||||
<label>
|
||||
<?= h(t('app.select_prompt', 'Select prompt')) ?><br>
|
||||
<select id="bootstrapPromptSelect">
|
||||
<?php foreach ($bootstrapPrompts as $prompt): ?>
|
||||
<option value="<?= h($prompt['id']) ?>"><?= h($prompt['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<textarea id="bootstrapPromptOutput" readonly rows="12"></textarea>
|
||||
|
||||
<button type="button"
|
||||
class="js-copy-button"
|
||||
data-copy-target="bootstrapPromptOutput"
|
||||
data-copy-label="<?= h(t('app.copy', 'Copy')) ?>"
|
||||
data-copied-label="<?= h(t('app.copied', 'Copied')) ?>">
|
||||
<?= h(t('app.copy_full_prompt', 'Copy full prompt')) ?>
|
||||
</button>
|
||||
</div>
|
||||
<script type="application/json" id="bootstrapPromptData">
|
||||
<?= json_encode(array(
|
||||
'link' => $issuedLink,
|
||||
'prompts' => $bootstrapPrompts,
|
||||
), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
|
||||
</script>
|
||||
<?php else: ?>
|
||||
<p class="small"><?= h(t('app.no_bootstrap_prompts', 'No personal prompts are available for this language. Copy a default prompt first from prompt management.')) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/clipboard.js" defer></script>
|
||||
<script src="/bootstrap-prompt.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,143 @@
|
||||
<?php
|
||||
/*
|
||||
* login.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$auth = new RacketSandboxAuth(__DIR__ . '/data/racket-sandbox.sqlite');
|
||||
|
||||
$error = '';
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function detect_login_language($supported, $fallback)
|
||||
{
|
||||
$header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
|
||||
$preferences = array();
|
||||
|
||||
foreach (explode(',', $header) as $part) {
|
||||
$pieces = array_map('trim', explode(';', $part));
|
||||
$lang = strtolower($pieces[0] ?? '');
|
||||
$quality = 1.0;
|
||||
|
||||
foreach (array_slice($pieces, 1) as $piece) {
|
||||
if (strpos($piece, 'q=') === 0) {
|
||||
$quality = (float)substr($piece, 2);
|
||||
}
|
||||
}
|
||||
|
||||
if ($lang !== '') {
|
||||
$preferences[] = array('lang' => $lang, 'quality' => $quality);
|
||||
}
|
||||
}
|
||||
|
||||
usort($preferences, function ($a, $b) {
|
||||
return $a['quality'] < $b['quality'] ? 1 : -1;
|
||||
});
|
||||
|
||||
foreach ($preferences as $preference) {
|
||||
$lang = $preference['lang'];
|
||||
$primary = explode('-', $lang, 2)[0];
|
||||
|
||||
if (isset($supported[$lang])) {
|
||||
return $lang;
|
||||
}
|
||||
|
||||
if (isset($supported[$primary])) {
|
||||
return $primary;
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$pageTitle = 'Racket ChatGPT Agent Sandbox Creator';
|
||||
$texts = array(
|
||||
'en' => array(
|
||||
'email' => 'Email address',
|
||||
'password' => 'Password',
|
||||
'login' => 'Login',
|
||||
'account_title' => 'Want to try it?',
|
||||
'account_text' => 'If you would like an account to try the sandbox, please request one from Hans Dijkema through the Racket Discourse pages.',
|
||||
'account_link' => 'Go to Racket Discourse',
|
||||
),
|
||||
'nl' => array(
|
||||
'email' => 'E-mailadres',
|
||||
'password' => 'Wachtwoord',
|
||||
'login' => 'Inloggen',
|
||||
'account_title' => 'Wil je het eens proberen?',
|
||||
'account_text' => 'Als je een account wilt om de sandbox eens uit te proberen, doe dan een verzoek aan Hans Dijkema via de Racket Discourse-pagina\'s.',
|
||||
'account_link' => 'Naar Racket Discourse',
|
||||
),
|
||||
);
|
||||
$language = detect_login_language($texts, 'en');
|
||||
$styleVersion = @filemtime(__DIR__ . '/styles.css') ?: time();
|
||||
|
||||
if ($auth->currentUser() !== null && $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$auth->login($_POST['email'] ?? '', $_POST['password'] ?? '');
|
||||
header('Location: /');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= h($language) ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= h($pageTitle) ?></title>
|
||||
<link rel="stylesheet" href="/styles.css?v=<?= h($styleVersion) ?>">
|
||||
</head>
|
||||
<body class="simple-doc login-page">
|
||||
|
||||
<main class="login-layout">
|
||||
<section class="login-panel">
|
||||
<h1><?= h($pageTitle) ?></h1>
|
||||
|
||||
<?php if ($error !== ''): ?>
|
||||
<div class="error"><?= h($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/login.php">
|
||||
|
||||
<label>
|
||||
<?= h($texts[$language]['email']) ?><br>
|
||||
<input type="email" name="email" autocomplete="username" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<?= h($texts[$language]['password']) ?><br>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit"><?= h($texts[$language]['login']) ?></button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="login-request-panel">
|
||||
<h2><?= h($texts[$language]['account_title']) ?></h2>
|
||||
<p><?= h($texts[$language]['account_text']) ?></p>
|
||||
<p><a href="https://racket.discourse.group/"><?= h($texts[$language]['account_link']) ?></a></p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
/*
|
||||
* make-user.php
|
||||
*
|
||||
* CLI tool om handmatig gebruikers aan te maken.
|
||||
*
|
||||
* Gebruik:
|
||||
*
|
||||
* php make-user.php <email> "<full name>" <password> <admin:0|1>
|
||||
*
|
||||
* Voorbeeld:
|
||||
*
|
||||
* php make-user.php hans@example.nl "Hans Dijkema" "lang-genoeg-wachtwoord" 1
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
echo "CLI only\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($argc < 5) {
|
||||
echo "Usage:\n";
|
||||
echo " php make-user.php <email> \"<full name>\" <password> <admin:0|1>\n\n";
|
||||
echo "Example:\n";
|
||||
echo " php make-user.php hans@example.nl \"Hans Dijkema\" \"lang-genoeg-wachtwoord\" 1\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$email = $argv[1];
|
||||
$fullName = $argv[2];
|
||||
$password = $argv[3];
|
||||
$isAdmin = $argv[4] === '1';
|
||||
|
||||
try {
|
||||
$auth = new RacketSandboxAuth(__DIR__ . '/data/racket-sandbox.sqlite');
|
||||
|
||||
$user = $auth->createUser($email, $fullName, $password, $isAdmin, true);
|
||||
|
||||
echo "Created user\n";
|
||||
echo "------------\n";
|
||||
echo "ID: " . $user->id() . "\n";
|
||||
echo "Email: " . $user->email() . "\n";
|
||||
echo "Full name: " . $user->fullName() . "\n";
|
||||
echo "Admin: " . ($user->isAdmin() ? "yes" : "no") . "\n";
|
||||
echo "Enabled: " . ($user->isEnabled() ? "yes" : "no") . "\n";
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
/*
|
||||
* nexttoken.php
|
||||
*
|
||||
* Eenvoudige SQLite-backed next-token store.
|
||||
*
|
||||
* Model:
|
||||
* - Iedere gegenereerde HTML-pagina maakt één next-token.
|
||||
* - Alle links op die pagina gebruiken datzelfde token.
|
||||
* - Vervolgroutes accepteren een request alleen als next bestaat
|
||||
* en nog niet verlopen is.
|
||||
* - Het token heeft verder geen betekenis.
|
||||
*/
|
||||
|
||||
class NextTokenException extends Exception
|
||||
{
|
||||
}
|
||||
|
||||
class NextTokenStore
|
||||
{
|
||||
private $db;
|
||||
private $ttlSeconds;
|
||||
|
||||
public function __construct($dbFile, $ttlSeconds = 21600)
|
||||
{
|
||||
$this->ttlSeconds = (int)$ttlSeconds;
|
||||
|
||||
$dir = dirname($dbFile);
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0700, true)) {
|
||||
throw new NextTokenException('Could not create token db dir: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
$this->db = new PDO('sqlite:' . $dbFile);
|
||||
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$this->db->exec('PRAGMA journal_mode = WAL');
|
||||
$this->db->exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
$this->init();
|
||||
}
|
||||
|
||||
private function init()
|
||||
{
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS next_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)'
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_next_tokens_expires
|
||||
ON next_tokens(expires_at)'
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS next_token_words (
|
||||
word TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
PRIMARY KEY(word, kind, language)
|
||||
)'
|
||||
);
|
||||
|
||||
$this->migrateTokenWordsTable();
|
||||
|
||||
$this->db->exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_next_token_words_kind
|
||||
ON next_token_words(kind, language)'
|
||||
);
|
||||
|
||||
$this->seedWords();
|
||||
}
|
||||
|
||||
private function migrateTokenWordsTable()
|
||||
{
|
||||
$stmt = $this->db->query('PRAGMA table_info(next_token_words)');
|
||||
$pkCols = array();
|
||||
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ((int)$row['pk'] > 0) {
|
||||
$pkCols[(int)$row['pk']] = $row['name'];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($pkCols);
|
||||
|
||||
if (array_values($pkCols) === array('word', 'kind', 'language')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
$this->db->exec(
|
||||
'CREATE TABLE next_token_words_new (
|
||||
word TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
PRIMARY KEY(word, kind, language)
|
||||
)'
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
'INSERT OR IGNORE INTO next_token_words_new (word, kind, language)
|
||||
SELECT word, kind, language
|
||||
FROM next_token_words'
|
||||
);
|
||||
|
||||
$this->db->exec('DROP TABLE next_token_words');
|
||||
$this->db->exec('ALTER TABLE next_token_words_new RENAME TO next_token_words');
|
||||
$this->db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function create($ttlSeconds = null)
|
||||
{
|
||||
$ttl = $ttlSeconds === null ? $this->ttlSeconds : (int)$ttlSeconds;
|
||||
|
||||
if ($ttl <= 0) {
|
||||
throw new NextTokenException('Invalid ttl.');
|
||||
}
|
||||
|
||||
$this->cleanup();
|
||||
|
||||
$now = time();
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO next_tokens (token, created_at, expires_at)
|
||||
VALUES (:token, :created_at, :expires_at)'
|
||||
);
|
||||
|
||||
for ($i = 0; $i < 25; $i++) {
|
||||
$token = $this->newToken();
|
||||
|
||||
try {
|
||||
$stmt->execute(array(
|
||||
':token' => $token,
|
||||
':created_at' => $now,
|
||||
':expires_at' => $now + $ttl,
|
||||
));
|
||||
|
||||
return $token;
|
||||
} catch (PDOException $e) {
|
||||
if ($e->getCode() !== '23000') {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new NextTokenException('Could not create unique next token.');
|
||||
}
|
||||
|
||||
public function isValid($token)
|
||||
{
|
||||
if (!is_string($token) || !preg_match('/^[a-z0-9-]{5,160}$/', $token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT expires_at
|
||||
FROM next_tokens
|
||||
WHERE token = :token'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':token' => $token));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return time() <= (int)$row['expires_at'];
|
||||
}
|
||||
|
||||
public function requireValid($token)
|
||||
{
|
||||
if (!$this->isValid($token)) {
|
||||
throw new NextTokenException('Invalid or expired next token.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cleanup()
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM next_tokens
|
||||
WHERE expires_at < :now'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':now' => time()));
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function currentTokens()
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT token, created_at, expires_at
|
||||
FROM next_tokens
|
||||
WHERE expires_at >= :now
|
||||
ORDER BY expires_at ASC, created_at ASC'
|
||||
);
|
||||
|
||||
$stmt->execute(array(':now' => time()));
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function tokens()
|
||||
{
|
||||
$stmt = $this->db->query(
|
||||
'SELECT token, created_at, expires_at
|
||||
FROM next_tokens
|
||||
ORDER BY expires_at ASC, created_at ASC'
|
||||
);
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function expired_html_response()
|
||||
{
|
||||
html_response('<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Expired link</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Expired or unauthorized link</h1>
|
||||
<p>
|
||||
This link is no longer valid. Please ask the human user for a new
|
||||
bootstrap link and restart the process from that link.
|
||||
</p>
|
||||
<p>
|
||||
For the AI agent: do not try to invent a new <code>next</code> value.
|
||||
Only follow links that appear on authorized HTML pages.
|
||||
</p>
|
||||
</body>
|
||||
</html>', 200);
|
||||
}
|
||||
|
||||
public function expired_text_response()
|
||||
{
|
||||
text_response(
|
||||
"Expired or unauthorized link.\n" .
|
||||
"Please ask the human user for a new bootstrap link.\n" .
|
||||
"Do not invent a new next value; only follow links from authorized HTML pages.\n",
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
public function expired_response($kind = 'html')
|
||||
{
|
||||
if ($kind === 'html') {
|
||||
$this->expired_html_response();
|
||||
} else {
|
||||
$this->expired_text_response();
|
||||
}
|
||||
}
|
||||
|
||||
public function check_valid_next($kind = 'html')
|
||||
{
|
||||
try {
|
||||
$this->requireValid($_GET['next'] ?? '');
|
||||
} catch (Throwable $e) {
|
||||
$this->expired_response($kind);
|
||||
}
|
||||
}
|
||||
|
||||
private function newToken()
|
||||
{
|
||||
$adjective = $this->randomWord('adjective');
|
||||
$noun = $this->randomWord('noun');
|
||||
|
||||
return $adjective . '-' . $noun;
|
||||
}
|
||||
|
||||
private function randomWord($kind)
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT word
|
||||
FROM next_token_words
|
||||
WHERE kind = :kind
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(array(':kind' => $kind));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new NextTokenException('No token words available for: ' . $kind);
|
||||
}
|
||||
|
||||
return (string)$row['word'];
|
||||
}
|
||||
|
||||
private function seedWords()
|
||||
{
|
||||
$wordsFile = __DIR__ . '/config/next-token-words.php';
|
||||
|
||||
if (!is_file($wordsFile)) {
|
||||
throw new NextTokenException('Next token word seed file not found.');
|
||||
}
|
||||
|
||||
$words = require $wordsFile;
|
||||
|
||||
if (!is_array($words)) {
|
||||
throw new NextTokenException('Invalid next token word seed file.');
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO next_token_words
|
||||
(word, kind, language)
|
||||
VALUES (:word, :kind, :language)'
|
||||
);
|
||||
|
||||
foreach ($words as $language => $byKind) {
|
||||
if (!is_array($byKind)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($byKind as $kind => $list) {
|
||||
if ($kind !== 'adjective' && $kind !== 'noun') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_array($list)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($list as $word) {
|
||||
$word = trim((string)$word);
|
||||
|
||||
if (!preg_match('/^[a-z0-9-]+$/', $word)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmt->execute(array(
|
||||
':word' => $word,
|
||||
':kind' => $kind,
|
||||
':language' => $language,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
<?php
|
||||
/*
|
||||
* package.php
|
||||
*
|
||||
* Vereist:
|
||||
*
|
||||
* gitfetcher.php
|
||||
* b64parts.php
|
||||
*
|
||||
* Routes:
|
||||
*
|
||||
* /package?name=<package>&next=...
|
||||
* HTML-pagina met base64 part-links voor data/<package>.zip.
|
||||
*
|
||||
* /package-part?name=<package>&part=000001&next=...
|
||||
* text/plain met base64-inhoud van één part.
|
||||
*
|
||||
* Regels:
|
||||
*
|
||||
* - HTML voor index/pagina's.
|
||||
* - text/plain voor payload.
|
||||
* - Payload is altijd base64.
|
||||
* - Eén next-id per gegenereerde HTML-pagina.
|
||||
*/
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/nexttoken.php';
|
||||
|
||||
$TOKENS = new NextTokenStore(__DIR__ . '/data/racket-sandbox.sqlite');
|
||||
|
||||
@set_time_limit(300);
|
||||
ignore_user_abort(false);
|
||||
|
||||
require_once __DIR__ . '/gitfetcher.php';
|
||||
require_once __DIR__ . '/b64parts.php';
|
||||
require_once __DIR__ . '/base64config.php';
|
||||
require_once __DIR__ . '/lib/catalog-http.php';
|
||||
require_once __DIR__ . '/lib/racket-data.php';
|
||||
|
||||
define('DATA_DIR', __DIR__ . '/data');
|
||||
define('CATALOG_PACKAGE_BASE', 'https://pkgs.racket-lang.org/pkg/');
|
||||
define('CATALOG_CACHE_TTL', 3600);
|
||||
|
||||
$chunkConfig = load_base64_chunk_config();
|
||||
$packageZipMaxBase64Kb = (int)($chunkConfig['package_zip_max_base64_kb'] ?? 2048);
|
||||
|
||||
if ($packageZipMaxBase64Kb < 1) {
|
||||
$packageZipMaxBase64Kb = 1;
|
||||
}
|
||||
|
||||
define('PACKAGE_ZIP_MAX_BASE64_KB', $packageZipMaxBase64Kb);
|
||||
define('PACKAGE_ZIP_MAX_BASE64_BYTES', PACKAGE_ZIP_MAX_BASE64_KB * 1024);
|
||||
|
||||
$NEXT_ID = $TOKENS->create();
|
||||
|
||||
function path_only()
|
||||
{
|
||||
$p = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
$p = '/' . trim($p ?: '/', '/');
|
||||
return $p === '/' ? '/' : $p;
|
||||
}
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function current_scheme()
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||
return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0]));
|
||||
}
|
||||
|
||||
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
}
|
||||
|
||||
function current_host()
|
||||
{
|
||||
return $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
}
|
||||
|
||||
function make_url($path, $query = array())
|
||||
{
|
||||
global $NEXT_ID;
|
||||
|
||||
$query['next'] = $NEXT_ID;
|
||||
|
||||
return current_scheme() . '://' . current_host() . $path . '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
function html_response($html, $status = 200)
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
function text_response($text, $status = 200)
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=us-ascii');
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
echo $text;
|
||||
|
||||
if ($text === '' || substr($text, -1) !== "\n") {
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
function fail_html($message, $status = 500)
|
||||
{
|
||||
html_response(
|
||||
'<!doctype html><html><head><meta charset="utf-8">' .
|
||||
'<title>Package error</title></head><body>' .
|
||||
'<h1>Package error</h1>' .
|
||||
'<pre>' . h($message) . '</pre>' .
|
||||
'</body></html>',
|
||||
$status
|
||||
);
|
||||
}
|
||||
|
||||
function fail_text($message, $status = 500)
|
||||
{
|
||||
text_response("error: " . $message . "\n", $status);
|
||||
}
|
||||
|
||||
function package_name_ok($name)
|
||||
{
|
||||
return rktd_package_name_ok($name);
|
||||
}
|
||||
|
||||
function ensure_data_dir()
|
||||
{
|
||||
try {
|
||||
catalog_http_ensure_dir(DATA_DIR);
|
||||
} catch (Throwable $e) {
|
||||
fail_html($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function catalog_cache_file($package)
|
||||
{
|
||||
ensure_data_dir();
|
||||
return DATA_DIR . '/catalog-' . sha1($package) . '.rktd';
|
||||
}
|
||||
|
||||
function catalog_meta_file($package)
|
||||
{
|
||||
ensure_data_dir();
|
||||
return DATA_DIR . '/catalog-' . sha1($package) . '.meta.json';
|
||||
}
|
||||
|
||||
function get_catalog_package_text($package)
|
||||
{
|
||||
$cacheFile = catalog_cache_file($package);
|
||||
$url = CATALOG_PACKAGE_BASE . rawurlencode($package);
|
||||
|
||||
try {
|
||||
return catalog_http_fetch_cached(
|
||||
$url,
|
||||
$cacheFile,
|
||||
catalog_meta_file($package),
|
||||
CATALOG_CACHE_TTL,
|
||||
'rktsndbx-package-entry/1.0',
|
||||
180
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
fail_html($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function extract_catalog_source($catalogText)
|
||||
{
|
||||
return rktd_extract_catalog_source($catalogText);
|
||||
}
|
||||
|
||||
function normalize_source_to_repo_url($source)
|
||||
{
|
||||
$source = trim((string)$source);
|
||||
|
||||
if ($source === '') {
|
||||
fail_html('Lege package source.');
|
||||
}
|
||||
|
||||
/*
|
||||
* github://github.com/owner/repo[/branch[/subdir]]
|
||||
* Voor nu gebruiken we owner/repo. Subdirs lossen we later op.
|
||||
*/
|
||||
$p = parse_url($source);
|
||||
|
||||
if ($p === false || empty($p['scheme'])) {
|
||||
fail_html('Kan package source niet parsen: ' . $source);
|
||||
}
|
||||
|
||||
$scheme = strtolower($p['scheme']);
|
||||
|
||||
if ($scheme === 'github') {
|
||||
$host = !empty($p['host']) ? strtolower($p['host']) : 'github.com';
|
||||
$path = trim($p['path'] ?? '', '/');
|
||||
$bits = explode('/', $path);
|
||||
|
||||
if ($host !== 'github.com' || count($bits) < 2) {
|
||||
fail_html('Ongeldige github package source: ' . $source);
|
||||
}
|
||||
|
||||
return 'https://github.com/' . $bits[0] . '/' . $bits[1];
|
||||
}
|
||||
|
||||
/*
|
||||
* git+https://... normaliseren. gitfetcher.php kan hier ook deels mee
|
||||
* omgaan, maar dit houdt metadata netter.
|
||||
*/
|
||||
if (strpos($source, 'git+https://') === 0) {
|
||||
return 'https://' . substr($source, strlen('git+https://'));
|
||||
}
|
||||
|
||||
if (strpos($source, 'git+http://') === 0) {
|
||||
return 'http://' . substr($source, strlen('git+http://'));
|
||||
}
|
||||
|
||||
/*
|
||||
* Verwijder query/fragment voor de repository-fetch.
|
||||
* ?path=... en #branch/subdir pakken we later apart aan.
|
||||
*/
|
||||
$source = preg_replace('/[?#].*$/', '', $source);
|
||||
|
||||
return $source;
|
||||
}
|
||||
|
||||
function ensure_package_zip_and_parts($package)
|
||||
{
|
||||
$catalogText = get_catalog_package_text($package);
|
||||
$source = extract_catalog_source($catalogText);
|
||||
|
||||
if ($source === null || $source === '') {
|
||||
fail_html('Geen source gevonden in Racket package catalogus voor package: ' . $package);
|
||||
}
|
||||
|
||||
$repoUrl = normalize_source_to_repo_url($source);
|
||||
|
||||
$fetcher = new GitFetcher(array(
|
||||
'data_dir' => DATA_DIR,
|
||||
));
|
||||
|
||||
try {
|
||||
$zipInfo = $fetcher->ensurePackageZip($package, $repoUrl);
|
||||
} catch (Throwable $e) {
|
||||
fail_html(
|
||||
"Kon package zip niet ophalen.\n\n" .
|
||||
"Package: " . $package . "\n" .
|
||||
"Catalog source: " . $source . "\n" .
|
||||
"Repo URL: " . $repoUrl . "\n\n" .
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
$zipFile = DATA_DIR . '/' . $package . '.zip';
|
||||
|
||||
if (!is_file($zipFile) || !is_readable($zipFile)) {
|
||||
fail_html('Zipbestand ontbreekt na fetch: ' . $zipFile);
|
||||
}
|
||||
|
||||
$parts = new Base64Parts(DATA_DIR, PACKAGE_ZIP_MAX_BASE64_BYTES);
|
||||
|
||||
try {
|
||||
$manifest = ensure_parts_for_zip($parts, $package, $zipFile);
|
||||
} catch (Throwable $e) {
|
||||
fail_html('Kon base64-parts niet maken: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return array(
|
||||
'package' => $package,
|
||||
'source' => $source,
|
||||
'repo_url' => $repoUrl,
|
||||
'zip_info' => $zipInfo,
|
||||
'manifest' => $manifest,
|
||||
);
|
||||
}
|
||||
|
||||
function ensure_parts_for_zip($parts, $package, $zipFile)
|
||||
{
|
||||
$zipSha = hash_file('sha256', $zipFile);
|
||||
$zipSize = filesize($zipFile);
|
||||
|
||||
/*
|
||||
* Hergebruik bestaande parts als ze nog exact bij de zip horen.
|
||||
*/
|
||||
try {
|
||||
$manifest = $parts->loadManifest($package);
|
||||
|
||||
if (isset($manifest['source_sha256']) &&
|
||||
$manifest['source_sha256'] === $zipSha &&
|
||||
isset($manifest['source_bytes']) &&
|
||||
(int)$manifest['source_bytes'] === (int)$zipSize &&
|
||||
isset($manifest['max_base64_bytes']) &&
|
||||
(int)$manifest['max_base64_bytes'] === PACKAGE_ZIP_MAX_BASE64_BYTES &&
|
||||
isset($manifest['parts']) &&
|
||||
is_array($manifest['parts']) &&
|
||||
count($manifest['parts']) > 0) {
|
||||
|
||||
$ok = true;
|
||||
|
||||
foreach ($manifest['parts'] as $part) {
|
||||
if (empty($part['file']) || !is_file($part['file']) || !is_readable($part['file'])) {
|
||||
$ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$manifest['parts_status'] = 'cached';
|
||||
return $manifest;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
/*
|
||||
* Geen manifest of ongeldig manifest: gewoon opnieuw maken.
|
||||
*/
|
||||
}
|
||||
|
||||
$manifest = $parts->splitFile($zipFile, $package, true);
|
||||
$manifest['parts_status'] = 'created';
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
function serve_package_page()
|
||||
{
|
||||
global $NEXT_ID;
|
||||
|
||||
$package = $_GET['name'] ?? '';
|
||||
|
||||
if (!package_name_ok($package)) {
|
||||
fail_html('Ongeldige of ontbrekende package naam.', 400);
|
||||
}
|
||||
|
||||
$info = ensure_package_zip_and_parts($package);
|
||||
|
||||
$manifest = $info['manifest'];
|
||||
$zipInfo = $info['zip_info'];
|
||||
|
||||
$rows = '';
|
||||
|
||||
foreach ($manifest['parts'] as $part) {
|
||||
$n = $part['number'];
|
||||
|
||||
$url = make_url('/package-part', array(
|
||||
'name' => $package,
|
||||
'part' => $n,
|
||||
));
|
||||
|
||||
$rows .=
|
||||
'<tr>' .
|
||||
'<td>' . h($n) . '</td>' .
|
||||
'<td>' . h((string)$part['base64_bytes']) . '</td>' .
|
||||
'<td><a href="' . h($url) . '">' . h($url) . '</a></td>' .
|
||||
'</tr>' . "\n";
|
||||
}
|
||||
|
||||
html_response('<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Package ' . h($package) . '</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body class="simple-doc">
|
||||
|
||||
<h1>Package ' . h($package) . '</h1>
|
||||
|
||||
<p>
|
||||
Deze pagina is HTML. Alle part-links hieronder geven <code>text/plain</code>
|
||||
met base64-inhoud terug. Dezelfde <code>next</code> wordt gebruikt voor alle
|
||||
part-links op deze pagina.
|
||||
</p>
|
||||
|
||||
<h2>Bron</h2>
|
||||
|
||||
<table>
|
||||
<tr><th>catalog source</th><td><code>' . h($info['source']) . '</code></td></tr>
|
||||
<tr><th>repo url</th><td><code>' . h($info['repo_url']) . '</code></td></tr>
|
||||
<tr><th>fetch status</th><td><code>' . h($zipInfo['status'] ?? '') . '</code></td></tr>
|
||||
<tr><th>default branch</th><td><code>' . h($zipInfo['default_branch'] ?? '') . '</code></td></tr>
|
||||
<tr><th>head sha</th><td><code>' . h($zipInfo['head_sha'] ?? '') . '</code></td></tr>
|
||||
<tr><th>zip file</th><td><code>' . h($zipInfo['zip_file'] ?? '') . '</code></td></tr>
|
||||
<tr><th>zip bytes</th><td><code>' . h((string)($zipInfo['zip_bytes'] ?? '')) . '</code></td></tr>
|
||||
<tr><th>zip sha256</th><td><code>' . h($zipInfo['zip_sha256'] ?? '') . '</code></td></tr>
|
||||
<tr><th>parts status</th><td><code>' . h($manifest['parts_status'] ?? '') . '</code></td></tr>
|
||||
<tr><th>max base64 part size</th><td><code>' . h((string)PACKAGE_ZIP_MAX_BASE64_KB) . '</code> KiB (<code>' . h((string)PACKAGE_ZIP_MAX_BASE64_BYTES) . '</code> bytes)</td></tr>
|
||||
<tr><th>binary chunk size</th><td><code>' . h((string)($manifest['binary_chunk_bytes'] ?? '')) . '</code> bytes</td></tr>
|
||||
<tr><th>part count</th><td><code>' . h((string)$manifest['part_count']) . '</code></td></tr>
|
||||
<tr><th>next id</th><td><code>' . h($NEXT_ID) . '</code></td></tr>
|
||||
</table>
|
||||
|
||||
<h2>Base64 parts</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>part</th>
|
||||
<th>base64 bytes</th>
|
||||
<th>text/plain URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
' . $rows . '
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Reconstructie in de sandbox</h2>
|
||||
|
||||
<pre>
|
||||
# download alle links als:
|
||||
# ' . h($package) . '.part.000001.b64
|
||||
# ' . h($package) . '.part.000002.b64
|
||||
# enz.
|
||||
|
||||
cat ' . h($package) . '.part.*.b64 > ' . h($package) . '.zip.b64
|
||||
base64 -d ' . h($package) . '.zip.b64 > ' . h($package) . '.zip
|
||||
raco pkg install --auto ./' . h($package) . '.zip
|
||||
</pre>
|
||||
|
||||
</body>
|
||||
</html>');
|
||||
}
|
||||
|
||||
function serve_package_part()
|
||||
{
|
||||
$package = $_GET['name'] ?? '';
|
||||
$part = $_GET['part'] ?? '';
|
||||
|
||||
if (!package_name_ok($package)) {
|
||||
fail_text('Ongeldige of ontbrekende package naam.', 400);
|
||||
}
|
||||
|
||||
if (!is_string($part) || !preg_match('/^[0-9]{6}$/', $part)) {
|
||||
fail_text('Ongeldig of ontbrekend partnummer.', 400);
|
||||
}
|
||||
|
||||
$parts = new Base64Parts(DATA_DIR, PACKAGE_ZIP_MAX_BASE64_BYTES);
|
||||
|
||||
try {
|
||||
$txt = $parts->readPart($package, $part);
|
||||
} catch (Throwable $e) {
|
||||
fail_text($e->getMessage(), 404);
|
||||
}
|
||||
|
||||
text_response($txt, 200);
|
||||
}
|
||||
|
||||
$path = path_only();
|
||||
|
||||
if ($path === '/package') {
|
||||
$TOKENS->check_valid_next('html');
|
||||
serve_package_page();
|
||||
}
|
||||
|
||||
if ($path === '/package-part') {
|
||||
$TOKENS->check_valid_next('text');
|
||||
serve_package_part();
|
||||
}
|
||||
|
||||
fail_html('Onbekende route: ' . $path, 404);
|
||||
@@ -0,0 +1,516 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let promptData = { personal: {}, default: {}, can_edit_defaults: false };
|
||||
let currentKind = null;
|
||||
let currentPrompt = null;
|
||||
let currentVersions = [];
|
||||
let versionIndex = 0;
|
||||
let uiText = {
|
||||
prompt_not_found: 'Prompt not found',
|
||||
no_previous_versions: 'No previous versions stored.',
|
||||
no_lines_for_view: 'No lines for this view.',
|
||||
restore_version_confirm: 'Restore version',
|
||||
delete_version_confirm: 'Delete version',
|
||||
default_prompt_prefix: 'Default prompt: ',
|
||||
prompt_prefix: 'Prompt: ',
|
||||
created: 'created',
|
||||
updated: 'updated',
|
||||
default_prompt: 'default prompt',
|
||||
version: 'version',
|
||||
showing_version: 'showing version',
|
||||
of: 'of',
|
||||
old: 'old',
|
||||
new: 'new'
|
||||
};
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function readPromptData() {
|
||||
const el = byId('promptDataJson');
|
||||
const textEl = byId('promptTextJson');
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
promptData = JSON.parse(el.textContent || '{}');
|
||||
} catch (e) {
|
||||
console.error('Could not parse prompt data JSON', e);
|
||||
promptData = { personal: {}, default: {} };
|
||||
}
|
||||
|
||||
if (!textEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uiText = Object.assign(uiText, JSON.parse(textEl.textContent || '{}'));
|
||||
} catch (e) {
|
||||
console.error('Could not parse prompt text JSON', e);
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function fmtTs(ts) {
|
||||
if (!ts) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
function promptActionUrl(kind) {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = '/prompts';
|
||||
url.searchParams.set('mode', kind === 'default' ? 'defaults' : 'personal');
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
function setEditing(editing) {
|
||||
const shell = byId('promptModalForm');
|
||||
const mayEdit = currentKind !== 'default' || promptData.can_edit_defaults;
|
||||
editing = editing && mayEdit;
|
||||
|
||||
shell.classList.toggle('edit-mode', editing);
|
||||
shell.classList.toggle('can-edit', mayEdit);
|
||||
shell.classList.toggle('read-only-default', currentKind === 'default' && !mayEdit);
|
||||
|
||||
byId('modalName').readOnly = !editing;
|
||||
byId('modalContent').readOnly = !editing;
|
||||
byId('modalDefaultKey').readOnly = !editing;
|
||||
byId('modalLanguage').disabled = !editing;
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
if (!currentPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentKind === 'default' && !promptData.can_edit_defaults) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(true);
|
||||
byId('promptModalBackdrop').classList.add('open');
|
||||
renderVersionPane();
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
byId('promptModalBackdrop').classList.remove('open');
|
||||
if (currentPrompt) {
|
||||
resetEditorFields();
|
||||
renderVersionPane();
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditorFields() {
|
||||
byId('modalName').value = currentPrompt.name;
|
||||
byId('modalLanguage').value = currentPrompt.language;
|
||||
byId('modalContent').value = currentPrompt.content;
|
||||
byId('modalDefaultKey').value = currentPrompt.default_key || '';
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function openPromptEditor(kind, id) {
|
||||
currentKind = kind;
|
||||
currentPrompt = promptData[kind] ? promptData[kind][id] : null;
|
||||
|
||||
if (!currentPrompt) {
|
||||
alert(uiText.prompt_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
currentVersions = currentPrompt.versions || [];
|
||||
currentVersions.sort(function (a, b) {
|
||||
return b.version_no - a.version_no;
|
||||
});
|
||||
|
||||
/*
|
||||
* The newest stored version is often the current snapshot. If possible,
|
||||
* start by showing the one before that.
|
||||
*/
|
||||
versionIndex = currentVersions.length > 1 ? 1 : 0;
|
||||
|
||||
byId('modalTitle').textContent =
|
||||
(kind === 'default' ? uiText.default_prompt_prefix : uiText.prompt_prefix) +
|
||||
currentPrompt.name;
|
||||
byId('viewerTitle').textContent =
|
||||
(kind === 'default' ? uiText.default_prompt_prefix : uiText.prompt_prefix) +
|
||||
currentPrompt.name;
|
||||
|
||||
byId('modalMeta').textContent =
|
||||
uiText.created + ' ' + fmtTs(currentPrompt.created_at) +
|
||||
' · ' + uiText.updated + ' ' + fmtTs(currentPrompt.updated_at) +
|
||||
(kind === 'default' && !promptData.can_edit_defaults ? ' · ' + uiText.default_prompt : '');
|
||||
byId('viewerMeta').textContent = byId('modalMeta').textContent;
|
||||
byId('viewerContent').textContent = currentPrompt.content;
|
||||
|
||||
byId('defaultKeyRow').style.display = kind === 'default' ? 'block' : 'none';
|
||||
|
||||
if (kind === 'default') {
|
||||
byId('modalAction').value = 'update_default';
|
||||
byId('modalDefaultId').value = currentPrompt.id;
|
||||
byId('modalPromptId').value = '';
|
||||
} else {
|
||||
byId('modalAction').value = 'update_prompt';
|
||||
byId('modalPromptId').value = currentPrompt.id;
|
||||
byId('modalDefaultId').value = '';
|
||||
}
|
||||
|
||||
byId('promptModalForm').action = promptActionUrl(kind);
|
||||
byId('modalAuxForm').action = promptActionUrl(kind);
|
||||
|
||||
byId('promptViewer').classList.remove('is-empty');
|
||||
byId('promptViewer').classList.toggle('is-default-prompt', kind === 'default');
|
||||
byId('promptModalBackdrop').classList.toggle('is-default-prompt', kind === 'default');
|
||||
byId('editPromptButton').style.display =
|
||||
kind === 'default' && !promptData.can_edit_defaults ? 'none' : '';
|
||||
document.querySelectorAll('.prompt-select').forEach(function (button) {
|
||||
button.classList.toggle(
|
||||
'selected',
|
||||
button.dataset.kind === kind && button.dataset.id === String(id)
|
||||
);
|
||||
});
|
||||
|
||||
resetEditorFields();
|
||||
renderVersionPane();
|
||||
}
|
||||
|
||||
function currentSelectedVersion() {
|
||||
if (currentVersions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (versionIndex < 0) {
|
||||
versionIndex = 0;
|
||||
}
|
||||
|
||||
if (versionIndex >= currentVersions.length) {
|
||||
versionIndex = currentVersions.length - 1;
|
||||
}
|
||||
|
||||
return currentVersions[versionIndex];
|
||||
}
|
||||
|
||||
function olderVersion() {
|
||||
if (currentVersions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (versionIndex < currentVersions.length - 1) {
|
||||
versionIndex++;
|
||||
renderVersionPane();
|
||||
}
|
||||
}
|
||||
|
||||
function newerVersion() {
|
||||
if (currentVersions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIndex = currentVersions.length > 1 ? 1 : 0;
|
||||
|
||||
if (versionIndex > minIndex) {
|
||||
versionIndex--;
|
||||
renderVersionPane();
|
||||
}
|
||||
}
|
||||
|
||||
function renderVersionPane() {
|
||||
const version = currentSelectedVersion();
|
||||
const contentEl = byId('versionContent');
|
||||
const metaEl = byId('selectedVersionMeta');
|
||||
const indicatorEl = byId('versionIndicator');
|
||||
|
||||
if (!version) {
|
||||
contentEl.innerHTML = '<span class="diff-muted">' +
|
||||
esc(uiText.no_previous_versions) +
|
||||
'</span>';
|
||||
metaEl.textContent = '';
|
||||
indicatorEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
metaEl.textContent =
|
||||
uiText.version + ' ' + version.version_no +
|
||||
' · ' + fmtTs(version.created_at) +
|
||||
(version.note ? ' · ' + version.note : '');
|
||||
|
||||
indicatorEl.textContent =
|
||||
uiText.showing_version + ' ' + version.version_no +
|
||||
' (' + (versionIndex + 1) + ' ' + uiText.of + ' ' + currentVersions.length + ')';
|
||||
|
||||
const currentText = byId('modalContent').value;
|
||||
const oldText = version.content;
|
||||
const mode = byId('diffMode').value;
|
||||
|
||||
if (mode === 'plain') {
|
||||
contentEl.innerHTML = esc(oldText);
|
||||
return;
|
||||
}
|
||||
|
||||
contentEl.innerHTML = renderLineDiff(oldText, currentText, mode);
|
||||
}
|
||||
|
||||
function linesOf(s) {
|
||||
return String(s).split(/\r?\n/);
|
||||
}
|
||||
|
||||
function lcsTable(a, b) {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp = Array.from({ length: m + 1 }, function () {
|
||||
return Array(n + 1).fill(0);
|
||||
});
|
||||
|
||||
for (let i = m - 1; i >= 0; i--) {
|
||||
for (let j = n - 1; j >= 0; j--) {
|
||||
if (a[i] === b[j]) {
|
||||
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp;
|
||||
}
|
||||
|
||||
function diffOps(oldLines, newLines) {
|
||||
const dp = lcsTable(oldLines, newLines);
|
||||
const ops = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
while (i < oldLines.length && j < newLines.length) {
|
||||
if (oldLines[i] === newLines[j]) {
|
||||
ops.push({ type: 'same', oldLine: oldLines[i], newLine: newLines[j] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ type: 'deleted', oldLine: oldLines[i] });
|
||||
i++;
|
||||
} else {
|
||||
ops.push({ type: 'added', newLine: newLines[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < oldLines.length) {
|
||||
ops.push({ type: 'deleted', oldLine: oldLines[i] });
|
||||
i++;
|
||||
}
|
||||
|
||||
while (j < newLines.length) {
|
||||
ops.push({ type: 'added', newLine: newLines[j] });
|
||||
j++;
|
||||
}
|
||||
|
||||
return pairChangedLines(ops);
|
||||
}
|
||||
|
||||
function pairChangedLines(ops) {
|
||||
const paired = [];
|
||||
|
||||
for (let k = 0; k < ops.length; k++) {
|
||||
const a = ops[k];
|
||||
const b = ops[k + 1];
|
||||
|
||||
if (a && b && a.type === 'deleted' && b.type === 'added') {
|
||||
paired.push({
|
||||
type: 'changed',
|
||||
oldLine: a.oldLine,
|
||||
newLine: b.newLine
|
||||
});
|
||||
k++;
|
||||
} else if (a && b && a.type === 'added' && b.type === 'deleted') {
|
||||
paired.push({
|
||||
type: 'changed',
|
||||
oldLine: b.oldLine,
|
||||
newLine: a.newLine
|
||||
});
|
||||
k++;
|
||||
} else {
|
||||
paired.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
return paired;
|
||||
}
|
||||
|
||||
function renderLineDiff(oldText, newText, mode) {
|
||||
const ops = diffOps(linesOf(oldText), linesOf(newText));
|
||||
const out = [];
|
||||
|
||||
for (const op of ops) {
|
||||
if (mode !== 'all' && mode !== op.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.type === 'same') {
|
||||
out.push('<span class="diff-line diff-same">' + esc(op.oldLine) + '</span>');
|
||||
} else if (op.type === 'added') {
|
||||
out.push('<span class="diff-line diff-added">+ ' + esc(op.newLine) + '</span>');
|
||||
} else if (op.type === 'deleted') {
|
||||
out.push('<span class="diff-line diff-deleted">- ' + esc(op.oldLine) + '</span>');
|
||||
} else if (op.type === 'changed') {
|
||||
out.push(
|
||||
'<span class="diff-line diff-changed">~ ' +
|
||||
esc(uiText.old) +
|
||||
': ' +
|
||||
esc(op.oldLine) +
|
||||
'</span>'
|
||||
);
|
||||
out.push(
|
||||
'<span class="diff-line diff-changed">~ ' +
|
||||
esc(uiText.new) +
|
||||
': ' +
|
||||
esc(op.newLine) +
|
||||
'</span>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length === 0) {
|
||||
return '<span class="diff-muted">' + esc(uiText.no_lines_for_view) + '</span>';
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function configureAux(action, versionNo) {
|
||||
const aux = byId('modalAuxForm');
|
||||
|
||||
byId('auxAction').value = action;
|
||||
byId('auxVersionNo').value = versionNo || '';
|
||||
byId('auxVersionNote').value = 'modal snapshot';
|
||||
|
||||
if (currentKind === 'default') {
|
||||
byId('auxDefaultId').value = currentPrompt.id;
|
||||
byId('auxPromptId').value = '';
|
||||
} else {
|
||||
byId('auxPromptId').value = currentPrompt.id;
|
||||
byId('auxDefaultId').value = '';
|
||||
}
|
||||
|
||||
return aux;
|
||||
}
|
||||
|
||||
function storeSnapshot() {
|
||||
if (!currentPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = currentKind === 'default'
|
||||
? 'create_default_version'
|
||||
: 'create_version';
|
||||
|
||||
configureAux(action, '').submit();
|
||||
}
|
||||
|
||||
function restoreSelectedVersion() {
|
||||
const version = currentSelectedVersion();
|
||||
|
||||
if (!currentPrompt || !version) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(uiText.restore_version_confirm + ' ' + version.version_no + '?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = currentKind === 'default'
|
||||
? 'restore_default_version'
|
||||
: 'restore_version';
|
||||
|
||||
configureAux(action, version.version_no).submit();
|
||||
}
|
||||
|
||||
function deleteSelectedVersion() {
|
||||
const version = currentSelectedVersion();
|
||||
|
||||
if (!currentPrompt || !version) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(uiText.delete_version_confirm + ' ' + version.version_no + '?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = currentKind === 'default'
|
||||
? 'delete_default_version'
|
||||
: 'delete_version';
|
||||
|
||||
configureAux(action, version.version_no).submit();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.querySelectorAll('.prompt-tab').forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
const tabName = tab.dataset.tab;
|
||||
|
||||
document.querySelectorAll('.prompt-tab').forEach(function (other) {
|
||||
const active = other === tab;
|
||||
other.classList.toggle('active', active);
|
||||
other.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.prompt-tab-panel').forEach(function (panel) {
|
||||
panel.classList.toggle('active', panel.id === 'tab-' + tabName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.js-open-prompt').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
openPromptEditor(button.dataset.kind, button.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
byId('editPromptButton').addEventListener('click', function () {
|
||||
openEditModal();
|
||||
});
|
||||
byId('cancelEditButton').addEventListener('click', function () {
|
||||
closeEditModal();
|
||||
});
|
||||
byId('versionNewerButton').addEventListener('click', newerVersion);
|
||||
byId('versionOlderButton').addEventListener('click', olderVersion);
|
||||
byId('diffMode').addEventListener('change', renderVersionPane);
|
||||
byId('modalContent').addEventListener('input', renderVersionPane);
|
||||
byId('snapshotButton').addEventListener('click', storeSnapshot);
|
||||
byId('restoreVersionButton').addEventListener('click', restoreSelectedVersion);
|
||||
byId('deleteVersionButton').addEventListener('click', deleteSelectedVersion);
|
||||
|
||||
byId('promptModalBackdrop').addEventListener('click', function (ev) {
|
||||
if (ev.target === byId('promptModalBackdrop')) {
|
||||
closeEditModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Escape') {
|
||||
closeEditModal();
|
||||
}
|
||||
});
|
||||
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
readPromptData();
|
||||
bindEvents();
|
||||
});
|
||||
}());
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
<?php
|
||||
/*
|
||||
* prompts.php
|
||||
*
|
||||
* User/admin prompt administration page.
|
||||
*
|
||||
* Normal users:
|
||||
* - manage personal prompts
|
||||
* - copy global default prompts to their own prompts
|
||||
*
|
||||
* Admin users:
|
||||
* - same as normal users
|
||||
* - manage global default prompts
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/header.php';
|
||||
require_once __DIR__ . '/languagestore.php';
|
||||
require_once __DIR__ . '/promptstore.php';
|
||||
require_once __DIR__ . '/usersettings.php';
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
|
||||
|
||||
$auth = new RacketSandboxAuth($DB_FILE);
|
||||
$user = $auth->requireLoginHtml();
|
||||
|
||||
$store = new PromptStore($DB_FILE);
|
||||
$languageStore = new LanguageStore($DB_FILE);
|
||||
$userSettings = new UserSettingsStore($DB_FILE);
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function t($key, $fallback = null)
|
||||
{
|
||||
global $languageStore, $language;
|
||||
|
||||
return $languageStore->translate($key, $language, $fallback);
|
||||
}
|
||||
|
||||
function post_value($name, $default = '')
|
||||
{
|
||||
return $_POST[$name] ?? $default;
|
||||
}
|
||||
|
||||
function get_value($name, $default = '')
|
||||
{
|
||||
return $_GET[$name] ?? $default;
|
||||
}
|
||||
|
||||
function fmt_time($ts)
|
||||
{
|
||||
if ($ts === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', (int)$ts);
|
||||
}
|
||||
|
||||
function prompt_to_array($prompt)
|
||||
{
|
||||
return array(
|
||||
'id' => $prompt->id(),
|
||||
'name' => $prompt->name(),
|
||||
'language' => $prompt->language(),
|
||||
'content' => $prompt->content(),
|
||||
'default_key' => $prompt->defaultKey(),
|
||||
'is_default' => $prompt->isDefault(),
|
||||
'created_at' => $prompt->createdAt(),
|
||||
'updated_at' => $prompt->updatedAt(),
|
||||
);
|
||||
}
|
||||
|
||||
function version_to_array($version)
|
||||
{
|
||||
return array(
|
||||
'id' => $version->id(),
|
||||
'prompt_id' => $version->promptId(),
|
||||
'version_no' => $version->versionNo(),
|
||||
'name' => $version->name(),
|
||||
'language' => $version->language(),
|
||||
'content' => $version->content(),
|
||||
'note' => $version->note(),
|
||||
'created_at' => $version->createdAt(),
|
||||
);
|
||||
}
|
||||
|
||||
function resolve_user_language($userSettings, $userId, $allowedLanguages)
|
||||
{
|
||||
$language = isset($_GET['lang'])
|
||||
? (string)$_GET['lang']
|
||||
: (string)$userSettings->get($userId, 'language', 'en');
|
||||
|
||||
if (!in_array($language, $allowedLanguages, true)) {
|
||||
$language = 'en';
|
||||
}
|
||||
|
||||
$userSettings->set($userId, 'language', $language);
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
$language = resolve_user_language(
|
||||
$userSettings,
|
||||
$user->id(),
|
||||
$store->supportedLanguages()
|
||||
);
|
||||
|
||||
$languageStore->seedDefaults(array(
|
||||
'prompts.title' => array('en' => 'Prompt administration', 'nl' => 'Promptbeheer'),
|
||||
'prompts.back' => array('en' => 'Back to Racket sandbox', 'nl' => 'Terug naar Racket sandbox'),
|
||||
'prompts.your_prompts' => array('en' => 'Your prompts', 'nl' => 'Jouw prompts'),
|
||||
'prompts.default_admin' => array('en' => 'Default prompt administration', 'nl' => 'Standaardpromptbeheer'),
|
||||
'prompts.language' => array('en' => 'Language', 'nl' => 'Taal'),
|
||||
'prompts.available_defaults' => array('en' => 'Available default prompts', 'nl' => 'Beschikbare standaardprompts'),
|
||||
'prompts.default_admin_badge' => array('en' => 'Admin default prompts', 'nl' => 'Admin standaardprompts'),
|
||||
'prompts.default_admin_hint' => array(
|
||||
'en' => 'You are editing global default prompts. Users can copy these to their own prompts.',
|
||||
'nl' => 'Je bewerkt globale standaardprompts. Gebruikers kunnen deze naar hun eigen prompts kopieren.'
|
||||
),
|
||||
'prompts.copy_all' => array('en' => 'Copy all', 'nl' => 'Alles kopieren'),
|
||||
'prompts.copy' => array('en' => 'copy', 'nl' => 'kopieer'),
|
||||
'prompts.delete' => array('en' => 'delete', 'nl' => 'verwijder'),
|
||||
'prompts.create_default' => array('en' => 'Create default prompt', 'nl' => 'Standaardprompt maken'),
|
||||
'prompts.create_personal' => array('en' => 'Create personal prompt', 'nl' => 'Persoonlijke prompt maken'),
|
||||
'prompts.name' => array('en' => 'Name', 'nl' => 'Naam'),
|
||||
'prompts.default_key' => array('en' => 'Default key', 'nl' => 'Standaardsleutel'),
|
||||
'prompts.prompt_content' => array('en' => 'Prompt content', 'nl' => 'Promptinhoud'),
|
||||
'prompts.no_defaults' => array('en' => 'No default prompts for this language yet.', 'nl' => 'Nog geen standaardprompts voor deze taal.'),
|
||||
'prompts.no_personal' => array('en' => 'No personal prompts yet for this language.', 'nl' => 'Nog geen persoonlijke prompts voor deze taal.'),
|
||||
'prompts.select_prompt' => array('en' => 'Select a prompt on the left to view it.', 'nl' => 'Selecteer links een prompt om deze te bekijken.'),
|
||||
'prompts.edit' => array('en' => 'Edit', 'nl' => 'Bewerk'),
|
||||
'prompts.close' => array('en' => 'Close', 'nl' => 'Sluiten'),
|
||||
'prompts.newer_previous' => array('en' => 'newer previous version', 'nl' => 'nieuwere vorige versie'),
|
||||
'prompts.older_previous' => array('en' => 'older previous version', 'nl' => 'oudere vorige versie'),
|
||||
'prompts.diff_view' => array('en' => 'Diff view:', 'nl' => 'Verschilweergave:'),
|
||||
'prompts.diff_plain' => array('en' => 'text, no diff', 'nl' => 'tekst, geen verschil'),
|
||||
'prompts.diff_all' => array('en' => 'all diff', 'nl' => 'alle verschillen'),
|
||||
'prompts.diff_same' => array('en' => 'unchanged only', 'nl' => 'alleen ongewijzigd'),
|
||||
'prompts.diff_added' => array('en' => 'additions only', 'nl' => 'alleen toevoegingen'),
|
||||
'prompts.diff_deleted' => array('en' => 'deletions only', 'nl' => 'alleen verwijderingen'),
|
||||
'prompts.diff_changed' => array('en' => 'changes only', 'nl' => 'alleen wijzigingen'),
|
||||
'prompts.previous_version' => array('en' => 'Previous version', 'nl' => 'Vorige versie'),
|
||||
'prompts.store_version' => array('en' => 'store this edit as a new version', 'nl' => 'bewaar deze bewerking als nieuwe versie'),
|
||||
'prompts.version_note' => array('en' => 'Version note:', 'nl' => 'Versienotitie:'),
|
||||
'prompts.save' => array('en' => 'Save', 'nl' => 'Opslaan'),
|
||||
'prompts.store_snapshot' => array('en' => 'Store snapshot', 'nl' => 'Snapshot bewaren'),
|
||||
'prompts.restore_version' => array('en' => 'Restore selected version', 'nl' => 'Geselecteerde versie herstellen'),
|
||||
'prompts.delete_version' => array('en' => 'Delete selected version', 'nl' => 'Geselecteerde versie verwijderen'),
|
||||
'prompts.prompt' => array('en' => 'Prompt', 'nl' => 'Prompt'),
|
||||
'prompts.prompt_not_found' => array('en' => 'Prompt not found', 'nl' => 'Prompt niet gevonden'),
|
||||
'prompts.no_previous_versions' => array('en' => 'No previous versions stored.', 'nl' => 'Geen vorige versies opgeslagen.'),
|
||||
'prompts.no_lines_for_view' => array('en' => 'No lines for this view.', 'nl' => 'Geen regels voor deze weergave.'),
|
||||
'prompts.restore_version_confirm' => array('en' => 'Restore version', 'nl' => 'Versie herstellen'),
|
||||
'prompts.delete_version_confirm' => array('en' => 'Delete version', 'nl' => 'Versie verwijderen'),
|
||||
'prompts.delete_default_confirm' => array('en' => 'Delete default prompt', 'nl' => 'Standaardprompt verwijderen'),
|
||||
'prompts.delete_prompt_confirm' => array('en' => 'Delete prompt', 'nl' => 'Prompt verwijderen'),
|
||||
'prompts.default_prompt_prefix' => array('en' => 'Default prompt: ', 'nl' => 'Standaardprompt: '),
|
||||
'prompts.prompt_prefix' => array('en' => 'Prompt: ', 'nl' => 'Prompt: '),
|
||||
'prompts.created' => array('en' => 'created', 'nl' => 'gemaakt'),
|
||||
'prompts.updated' => array('en' => 'updated', 'nl' => 'bijgewerkt'),
|
||||
'prompts.default_prompt' => array('en' => 'default prompt', 'nl' => 'standaardprompt'),
|
||||
'prompts.version' => array('en' => 'version', 'nl' => 'versie'),
|
||||
'prompts.showing_version' => array('en' => 'showing version', 'nl' => 'toont versie'),
|
||||
'prompts.of' => array('en' => 'of', 'nl' => 'van'),
|
||||
'prompts.old' => array('en' => 'old', 'nl' => 'oud'),
|
||||
'prompts.new' => array('en' => 'new', 'nl' => 'nieuw'),
|
||||
'app.admin' => array('en' => 'admin', 'nl' => 'admin'),
|
||||
'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'),
|
||||
'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'),
|
||||
'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'),
|
||||
));
|
||||
|
||||
$mode = get_value('mode', 'personal');
|
||||
|
||||
if ($mode !== 'personal' && $mode !== 'defaults') {
|
||||
$mode = 'personal';
|
||||
}
|
||||
|
||||
if ($mode === 'defaults' && !$user->isAdmin()) {
|
||||
$mode = 'personal';
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = post_value('action');
|
||||
|
||||
try {
|
||||
/*
|
||||
* User actions.
|
||||
*/
|
||||
if ($action === 'logout') {
|
||||
$auth->logout();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
} elseif ($action === 'copy_all_defaults') {
|
||||
$language = post_value('language', 'en');
|
||||
$created = $store->copyAllDefaultsToUser($user->id(), $language);
|
||||
$message = 'Default prompts copied to your prompts: ' . $created;
|
||||
$mode = 'personal';
|
||||
} elseif ($action === 'copy_default') {
|
||||
$defaultId = (int)post_value('default_id');
|
||||
$store->copyDefaultPromptToUser($user->id(), $defaultId);
|
||||
$message = 'Default prompt copied to your prompts.';
|
||||
$mode = 'personal';
|
||||
} elseif ($action === 'create_prompt') {
|
||||
$language = post_value('language', 'en');
|
||||
|
||||
$store->createPrompt(
|
||||
$user->id(),
|
||||
post_value('name'),
|
||||
$language,
|
||||
post_value('content')
|
||||
);
|
||||
|
||||
$message = 'Prompt created.';
|
||||
$mode = 'personal';
|
||||
} elseif ($action === 'update_prompt') {
|
||||
$promptId = (int)post_value('prompt_id');
|
||||
$language = post_value('language', 'en');
|
||||
|
||||
$store->updatePrompt(
|
||||
$user->id(),
|
||||
$promptId,
|
||||
post_value('name'),
|
||||
$language,
|
||||
post_value('content'),
|
||||
isset($_POST['create_version']),
|
||||
post_value('version_note')
|
||||
);
|
||||
|
||||
$message = 'Prompt updated.';
|
||||
$mode = 'personal';
|
||||
} elseif ($action === 'create_version') {
|
||||
$promptId = (int)post_value('prompt_id');
|
||||
$store->createVersion($user->id(), $promptId, post_value('version_note'));
|
||||
$message = 'Version stored.';
|
||||
$mode = 'personal';
|
||||
} elseif ($action === 'restore_version') {
|
||||
$promptId = (int)post_value('prompt_id');
|
||||
$versionNo = (int)post_value('version_no');
|
||||
$store->restoreVersion($user->id(), $promptId, $versionNo);
|
||||
$message = 'Version restored.';
|
||||
$mode = 'personal';
|
||||
} elseif ($action === 'delete_version') {
|
||||
$promptId = (int)post_value('prompt_id');
|
||||
$versionNo = (int)post_value('version_no');
|
||||
$store->deleteVersion($user->id(), $promptId, $versionNo);
|
||||
$message = 'Version deleted.';
|
||||
$mode = 'personal';
|
||||
} elseif ($action === 'delete_prompt') {
|
||||
$promptId = (int)post_value('prompt_id');
|
||||
$store->deletePrompt($user->id(), $promptId);
|
||||
$message = 'Prompt deleted.';
|
||||
$mode = 'personal';
|
||||
}
|
||||
|
||||
/*
|
||||
* Admin-only default prompt actions.
|
||||
*/
|
||||
elseif ($action === 'create_default' ||
|
||||
$action === 'update_default' ||
|
||||
$action === 'create_default_version' ||
|
||||
$action === 'restore_default_version' ||
|
||||
$action === 'delete_default_version' ||
|
||||
$action === 'delete_default') {
|
||||
|
||||
if (!$user->isAdmin()) {
|
||||
throw new Exception('Admin rights required.');
|
||||
}
|
||||
|
||||
$mode = 'defaults';
|
||||
|
||||
if ($action === 'create_default') {
|
||||
$language = post_value('language', 'en');
|
||||
|
||||
$store->createDefaultPrompt(
|
||||
post_value('default_key'),
|
||||
post_value('name'),
|
||||
$language,
|
||||
post_value('content')
|
||||
);
|
||||
|
||||
$message = 'Default prompt created.';
|
||||
} elseif ($action === 'update_default') {
|
||||
$defaultId = (int)post_value('default_id');
|
||||
$language = post_value('language', 'en');
|
||||
|
||||
$store->updateDefaultPrompt(
|
||||
$defaultId,
|
||||
post_value('default_key'),
|
||||
post_value('name'),
|
||||
$language,
|
||||
post_value('content'),
|
||||
isset($_POST['create_version']),
|
||||
post_value('version_note')
|
||||
);
|
||||
|
||||
$message = 'Default prompt updated.';
|
||||
} elseif ($action === 'create_default_version') {
|
||||
$defaultId = (int)post_value('default_id');
|
||||
$store->createDefaultVersion($defaultId, post_value('version_note'));
|
||||
$message = 'Default prompt version stored.';
|
||||
} elseif ($action === 'restore_default_version') {
|
||||
$defaultId = (int)post_value('default_id');
|
||||
$versionNo = (int)post_value('version_no');
|
||||
$store->restoreDefaultVersion($defaultId, $versionNo);
|
||||
$message = 'Default prompt version restored.';
|
||||
} elseif ($action === 'delete_default_version') {
|
||||
$defaultId = (int)post_value('default_id');
|
||||
$versionNo = (int)post_value('version_no');
|
||||
$store->deleteDefaultVersion($defaultId, $versionNo);
|
||||
$message = 'Default prompt version deleted.';
|
||||
} elseif ($action === 'delete_default') {
|
||||
$defaultId = (int)post_value('default_id');
|
||||
$store->deleteDefaultPrompt($defaultId);
|
||||
$message = 'Default prompt deleted.';
|
||||
}
|
||||
} elseif ($action !== '') {
|
||||
throw new Exception('Unknown action: ' . $action);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$personalPrompts = $store->listPrompts($user->id(), $language);
|
||||
$defaultPrompts = $store->listDefaultPrompts($language);
|
||||
|
||||
/*
|
||||
* Full prompt data for the modal editor.
|
||||
*/
|
||||
$allPersonalPrompts = $store->listPrompts($user->id(), null);
|
||||
$allDefaultPrompts = $store->listDefaultPrompts(null);
|
||||
|
||||
$promptData = array(
|
||||
'personal' => array(),
|
||||
'default' => array(),
|
||||
'can_edit_defaults' => $user->isAdmin(),
|
||||
);
|
||||
|
||||
foreach ($allPersonalPrompts as $p) {
|
||||
$promptData['personal'][$p->id()] = prompt_to_array($p);
|
||||
$promptData['personal'][$p->id()]['versions'] = array();
|
||||
|
||||
foreach ($store->listVersions($user->id(), $p->id()) as $v) {
|
||||
$promptData['personal'][$p->id()]['versions'][] = version_to_array($v);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($allDefaultPrompts as $p) {
|
||||
$promptData['default'][$p->id()] = prompt_to_array($p);
|
||||
$promptData['default'][$p->id()]['versions'] = array();
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
foreach ($store->listDefaultVersions($p->id()) as $v) {
|
||||
$promptData['default'][$p->id()]['versions'][] = version_to_array($v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$headerLanguages = array();
|
||||
|
||||
foreach ($store->supportedLanguages() as $lang) {
|
||||
$headerLanguages[$lang] = $store->languageLabel($lang);
|
||||
}
|
||||
|
||||
$headerNavItems = array(
|
||||
array(
|
||||
'label' => t('prompts.back', 'Back to Racket sandbox'),
|
||||
'url' => '/',
|
||||
),
|
||||
);
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
$headerNavItems[] = array(
|
||||
'label' => t('app.user_management', 'User management'),
|
||||
'url' => '/users?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
);
|
||||
$headerNavItems[] = array(
|
||||
'label' => t('app.configuration', 'Configuration'),
|
||||
'url' => '/admin-config?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
);
|
||||
}
|
||||
|
||||
$styleVersion = @filemtime(__DIR__ . '/styles.css') ?: time();
|
||||
$promptEditorVersion = @filemtime(__DIR__ . '/prompt-editor.js') ?: time();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= h($language) ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= h(t('prompts.title', 'Prompt administration')) ?></title>
|
||||
<link rel="stylesheet" href="/styles.css?v=<?= h($styleVersion) ?>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<?php
|
||||
render_app_header(array(
|
||||
'title' => t('prompts.title', 'Prompt administration'),
|
||||
'nav_items' => $headerNavItems,
|
||||
'user' => $user,
|
||||
'admin_label' => t('app.admin', 'admin'),
|
||||
'language_label' => t('prompts.language', 'Language'),
|
||||
'language' => $language,
|
||||
'languages' => $headerLanguages,
|
||||
'language_action' => '/prompts',
|
||||
'language_hidden' => array('mode' => $mode),
|
||||
'logout_action' => '/prompts?lang=' . rawurlencode($language) . '&mode=' . rawurlencode($mode),
|
||||
'logout_label' => t('app.logout', 'Logout'),
|
||||
'message' => $message,
|
||||
'error' => $error,
|
||||
));
|
||||
?>
|
||||
|
||||
<main class="page-main prompt-workbench">
|
||||
|
||||
<aside class="prompt-sidebar panel">
|
||||
<div class="prompt-tabs" role="tablist" aria-label="Prompt lists">
|
||||
<button type="button" class="prompt-tab <?= $mode === 'defaults' ? 'active' : '' ?>" data-tab="defaults" role="tab" aria-selected="<?= $mode === 'defaults' ? 'true' : 'false' ?>">
|
||||
<?= h(t('prompts.available_defaults', 'Available default prompts')) ?>
|
||||
</button>
|
||||
<button type="button" class="prompt-tab <?= $mode !== 'defaults' ? 'active' : '' ?>" data-tab="personal" role="tab" aria-selected="<?= $mode !== 'defaults' ? 'true' : 'false' ?>">
|
||||
<?= h(t('prompts.your_prompts', 'Your prompts')) ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="prompt-tab-panel <?= $mode === 'defaults' ? 'active' : '' ?>" id="tab-defaults" role="tabpanel">
|
||||
<?php if ($user->isAdmin()): ?>
|
||||
<div class="default-admin-notice">
|
||||
<strong><?= h(t('prompts.default_admin_badge', 'Admin default prompts')) ?></strong>
|
||||
<span><?= h(t('prompts.default_admin_hint', 'You are editing global default prompts. Users can copy these to their own prompts.')) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="sidebar-actions">
|
||||
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal">
|
||||
<input type="hidden" name="action" value="copy_all_defaults">
|
||||
<input type="hidden" name="language" value="<?= h($language) ?>">
|
||||
<button type="submit"><?= h(t('prompts.copy_all', 'Copy all')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="prompt-list">
|
||||
<?php foreach ($defaultPrompts as $default): ?>
|
||||
<div class="prompt-list-item default-prompt-item">
|
||||
<button type="button"
|
||||
class="prompt-select js-open-prompt"
|
||||
data-kind="default"
|
||||
data-id="<?= h($default->id()) ?>">
|
||||
<span class="prompt-name"><?= h($default->name()) ?></span>
|
||||
<span class="prompt-subline"><code><?= h($default->defaultKey()) ?></code> · <?= h(fmt_time($default->updatedAt())) ?></span>
|
||||
</button>
|
||||
|
||||
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal">
|
||||
<input type="hidden" name="action" value="copy_default">
|
||||
<input type="hidden" name="default_id" value="<?= h($default->id()) ?>">
|
||||
<button type="submit"><?= h(t('prompts.copy', 'copy')) ?></button>
|
||||
</form>
|
||||
|
||||
<?php if ($user->isAdmin()): ?>
|
||||
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=defaults"
|
||||
onsubmit="return confirm('<?= h(t('prompts.delete_default_confirm', 'Delete default prompt')) ?> <?= h($default->name()) ?>?');">
|
||||
<input type="hidden" name="action" value="delete_default">
|
||||
<input type="hidden" name="default_id" value="<?= h($default->id()) ?>">
|
||||
<button type="submit"><?= h(t('prompts.delete', 'delete')) ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (count($defaultPrompts) === 0): ?>
|
||||
<p class="empty-state"><?= h(t('prompts.no_defaults', 'No default prompts for this language yet.')) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($user->isAdmin()): ?>
|
||||
<details class="create-drawer">
|
||||
<summary><?= h(t('prompts.create_default', 'Create default prompt')) ?></summary>
|
||||
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=defaults">
|
||||
<input type="hidden" name="action" value="create_default">
|
||||
<label><?= h(t('prompts.default_key', 'Default key')) ?><br><input type="text" name="default_key" placeholder="bootstrap-racket"></label>
|
||||
<label><?= h(t('prompts.name', 'Name')) ?><br><input type="text" name="name"></label>
|
||||
<label><?= h(t('prompts.language', 'Language')) ?><br>
|
||||
<select name="language">
|
||||
<?php foreach ($store->supportedLanguages() as $lang): ?>
|
||||
<option value="<?= h($lang) ?>" <?= $lang === $language ? 'selected' : '' ?>>
|
||||
<?= h($store->languageLabel($lang)) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label><?= h(t('prompts.prompt_content', 'Prompt content')) ?><br><textarea name="content" rows="7"></textarea></label>
|
||||
<button type="submit"><?= h(t('prompts.create_default', 'Create default prompt')) ?></button>
|
||||
</form>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="prompt-tab-panel <?= $mode !== 'defaults' ? 'active' : '' ?>" id="tab-personal" role="tabpanel">
|
||||
<div class="prompt-list">
|
||||
<?php foreach ($personalPrompts as $prompt): ?>
|
||||
<div class="prompt-list-item">
|
||||
<button type="button"
|
||||
class="prompt-select js-open-prompt"
|
||||
data-kind="personal"
|
||||
data-id="<?= h($prompt->id()) ?>">
|
||||
<span class="prompt-name"><?= h($prompt->name()) ?></span>
|
||||
<span class="prompt-subline"><?= h($store->languageLabel($prompt->language())) ?> · <?= h(fmt_time($prompt->updatedAt())) ?></span>
|
||||
</button>
|
||||
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal"
|
||||
onsubmit="return confirm('<?= h(t('prompts.delete_prompt_confirm', 'Delete prompt')) ?> <?= h($prompt->name()) ?>?');">
|
||||
<input type="hidden" name="action" value="delete_prompt">
|
||||
<input type="hidden" name="prompt_id" value="<?= h($prompt->id()) ?>">
|
||||
<button type="submit"><?= h(t('prompts.delete', 'delete')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (count($personalPrompts) === 0): ?>
|
||||
<p class="empty-state"><?= h(t('prompts.no_personal', 'No personal prompts yet for this language.')) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<details class="create-drawer">
|
||||
<summary><?= h(t('prompts.create_personal', 'Create personal prompt')) ?></summary>
|
||||
<form method="post" action="/prompts?lang=<?= h($language) ?>&mode=personal">
|
||||
<input type="hidden" name="action" value="create_prompt">
|
||||
<label><?= h(t('prompts.name', 'Name')) ?><br><input type="text" name="name"></label>
|
||||
<label><?= h(t('prompts.language', 'Language')) ?><br>
|
||||
<select name="language">
|
||||
<?php foreach ($store->supportedLanguages() as $lang): ?>
|
||||
<option value="<?= h($lang) ?>" <?= $lang === $language ? 'selected' : '' ?>>
|
||||
<?= h($store->languageLabel($lang)) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label><?= h(t('prompts.prompt_content', 'Prompt content')) ?><br><textarea name="content" rows="7"></textarea></label>
|
||||
<button type="submit"><?= h(t('prompts.create_personal', 'Create prompt')) ?></button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section id="promptViewer" class="prompt-viewer panel is-empty">
|
||||
<div class="viewer-empty">
|
||||
<?= h(t('prompts.select_prompt', 'Select a prompt on the left to view it.')) ?>
|
||||
</div>
|
||||
|
||||
<div class="viewer-shell">
|
||||
<div class="viewer-header">
|
||||
<div>
|
||||
<div class="viewer-title" id="viewerTitle"><?= h(t('prompts.prompt', 'Prompt')) ?></div>
|
||||
<div class="version-meta" id="viewerMeta"></div>
|
||||
</div>
|
||||
<button type="button" id="editPromptButton"><?= h(t('prompts.edit', 'Edit')) ?></button>
|
||||
</div>
|
||||
<pre id="viewerContent" class="viewer-content"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">
|
||||
<a href="/"><?= h(t('prompts.back', 'Back to Racket sandbox')) ?></a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="promptModalBackdrop" class="modal-backdrop">
|
||||
<div class="prompt-modal">
|
||||
|
||||
<form id="promptModalForm" class="edit-mode" method="post" action="/prompts?lang=<?= h($language) ?>&mode=<?= h($mode) ?>">
|
||||
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div class="modal-title" id="modalTitle"><?= h(t('prompts.prompt', 'Prompt')) ?></div>
|
||||
<div class="version-meta" id="modalMeta"></div>
|
||||
</div>
|
||||
<button type="button" id="cancelEditButton"><?= h(t('prompts.close', 'Close')) ?></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-toolbar">
|
||||
<button type="button" id="versionNewerButton"><?= h(t('prompts.newer_previous', 'newer previous version')) ?></button>
|
||||
<button type="button" id="versionOlderButton"><?= h(t('prompts.older_previous', 'older previous version')) ?></button>
|
||||
|
||||
<label>
|
||||
<?= h(t('prompts.diff_view', 'Diff view:')) ?>
|
||||
<select id="diffMode">
|
||||
<option value="plain"><?= h(t('prompts.diff_plain', 'text, no diff')) ?></option>
|
||||
<option value="all" selected><?= h(t('prompts.diff_all', 'all diff')) ?></option>
|
||||
<option value="same"><?= h(t('prompts.diff_same', 'unchanged only')) ?></option>
|
||||
<option value="added"><?= h(t('prompts.diff_added', 'additions only')) ?></option>
|
||||
<option value="deleted"><?= h(t('prompts.diff_deleted', 'deletions only')) ?></option>
|
||||
<option value="changed"><?= h(t('prompts.diff_changed', 'changes only')) ?></option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span id="versionIndicator" class="version-meta"></span>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="edit-pane">
|
||||
<div class="modal-form-grid">
|
||||
<label>
|
||||
<?= h(t('prompts.name', 'Name')) ?><br>
|
||||
<input type="text" id="modalName" name="name">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<?= h(t('prompts.language', 'Language')) ?><br>
|
||||
<select id="modalLanguage" name="language">
|
||||
<?php foreach ($store->supportedLanguages() as $lang): ?>
|
||||
<option value="<?= h($lang) ?>"><?= h($store->languageLabel($lang)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="defaultKeyRow" class="default-key-row">
|
||||
<label>
|
||||
<?= h(t('prompts.default_key', 'Default key')) ?><br>
|
||||
<input type="text" id="modalDefaultKey" name="default_key">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="content-label">
|
||||
<span><?= h(t('prompts.prompt_content', 'Prompt content')) ?></span>
|
||||
<textarea id="modalContent" name="content"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="version-pane">
|
||||
<div>
|
||||
<strong><?= h(t('prompts.previous_version', 'Previous version')) ?></strong>
|
||||
<div id="selectedVersionMeta" class="version-meta"></div>
|
||||
</div>
|
||||
|
||||
<div class="version-content" id="versionContent"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" id="modalAction" name="action" value="">
|
||||
<input type="hidden" id="modalPromptId" name="prompt_id" value="">
|
||||
<input type="hidden" id="modalDefaultId" name="default_id" value="">
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="create_version" value="1" checked>
|
||||
<?= h(t('prompts.store_version', 'store this edit as a new version')) ?>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<?= h(t('prompts.version_note', 'Version note:')) ?>
|
||||
<input type="text" name="version_note" value="editor edit">
|
||||
</label>
|
||||
|
||||
<button type="submit"><?= h(t('prompts.save', 'Save')) ?></button>
|
||||
<button type="button" id="snapshotButton"><?= h(t('prompts.store_snapshot', 'Store snapshot')) ?></button>
|
||||
<button type="button" id="restoreVersionButton"><?= h(t('prompts.restore_version', 'Restore selected version')) ?></button>
|
||||
<button type="button" id="deleteVersionButton"><?= h(t('prompts.delete_version', 'Delete selected version')) ?></button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="modalAuxForm" method="post" action="/prompts?lang=<?= h($language) ?>&mode=<?= h($mode) ?>" class="hidden">
|
||||
<input type="hidden" id="auxAction" name="action" value="">
|
||||
<input type="hidden" id="auxPromptId" name="prompt_id" value="">
|
||||
<input type="hidden" id="auxDefaultId" name="default_id" value="">
|
||||
<input type="hidden" id="auxVersionNo" name="version_no" value="">
|
||||
<input type="hidden" id="auxVersionNote" name="version_note" value="">
|
||||
</form>
|
||||
|
||||
<script type="application/json" id="promptDataJson">
|
||||
<?= json_encode($promptData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>
|
||||
</script>
|
||||
<script type="application/json" id="promptTextJson">
|
||||
<?= json_encode(array(
|
||||
'prompt_not_found' => t('prompts.prompt_not_found', 'Prompt not found'),
|
||||
'no_previous_versions' => t('prompts.no_previous_versions', 'No previous versions stored.'),
|
||||
'no_lines_for_view' => t('prompts.no_lines_for_view', 'No lines for this view.'),
|
||||
'restore_version_confirm' => t('prompts.restore_version_confirm', 'Restore version'),
|
||||
'delete_version_confirm' => t('prompts.delete_version_confirm', 'Delete version'),
|
||||
'default_prompt_prefix' => t('prompts.default_prompt_prefix', 'Default prompt: '),
|
||||
'prompt_prefix' => t('prompts.prompt_prefix', 'Prompt: '),
|
||||
'created' => t('prompts.created', 'created'),
|
||||
'updated' => t('prompts.updated', 'updated'),
|
||||
'default_prompt' => t('prompts.default_prompt', 'default prompt'),
|
||||
'version' => t('prompts.version', 'version'),
|
||||
'showing_version' => t('prompts.showing_version', 'showing version'),
|
||||
'of' => t('prompts.of', 'of'),
|
||||
'old' => t('prompts.old', 'old'),
|
||||
'new' => t('prompts.new', 'new'),
|
||||
), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>
|
||||
</script>
|
||||
<script src="/prompt-editor.js?v=<?= h($promptEditorVersion) ?>" defer></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+1075
File diff suppressed because it is too large
Load Diff
+196
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
/*
|
||||
* Shared handling for the Racket installation zip and its binary parts.
|
||||
*/
|
||||
|
||||
define('RACKET_ZIP_FILE', __DIR__ . '/config/racket.zip');
|
||||
define('RACKET_ZIP_DATA_DIR', __DIR__ . '/data');
|
||||
define('RACKET_ZIP_PART_PREFIX', 'racket-part-');
|
||||
define('RACKET_ZIP_MANIFEST_FILE', RACKET_ZIP_DATA_DIR . '/racket-parts.json');
|
||||
|
||||
function racket_zip_binary_chunk_bytes_for_base64_kib($kib)
|
||||
{
|
||||
return intdiv(((int)$kib) * 1024, 4) * 3;
|
||||
}
|
||||
|
||||
function racket_zip_part_name($nr)
|
||||
{
|
||||
return RACKET_ZIP_PART_PREFIX . sprintf('%06d', $nr);
|
||||
}
|
||||
|
||||
function racket_zip_part_file($partNumber)
|
||||
{
|
||||
return RACKET_ZIP_DATA_DIR . '/' . RACKET_ZIP_PART_PREFIX . $partNumber;
|
||||
}
|
||||
|
||||
function racket_zip_ensure_data_dir()
|
||||
{
|
||||
if (!is_dir(RACKET_ZIP_DATA_DIR)) {
|
||||
if (!mkdir(RACKET_ZIP_DATA_DIR, 0755, true)) {
|
||||
throw new RuntimeException('Kan data directory niet maken: ' . RACKET_ZIP_DATA_DIR);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable(RACKET_ZIP_DATA_DIR)) {
|
||||
throw new RuntimeException('Data directory is niet schrijfbaar: ' . RACKET_ZIP_DATA_DIR);
|
||||
}
|
||||
}
|
||||
|
||||
function racket_zip_remove_old_parts()
|
||||
{
|
||||
foreach (glob(RACKET_ZIP_DATA_DIR . '/' . RACKET_ZIP_PART_PREFIX . '*') ?: array() as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_file(RACKET_ZIP_MANIFEST_FILE)) {
|
||||
@unlink(RACKET_ZIP_MANIFEST_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
function split_racket_zip_parts($maxBase64Kib)
|
||||
{
|
||||
$maxBase64Bytes = (int)$maxBase64Kib * 1024;
|
||||
$binaryChunkBytes = racket_zip_binary_chunk_bytes_for_base64_kib($maxBase64Kib);
|
||||
|
||||
if ($binaryChunkBytes < 3) {
|
||||
throw new RuntimeException('Ongeldige racket.zip base64 chunk-instelling.');
|
||||
}
|
||||
|
||||
if (!is_file(RACKET_ZIP_FILE) || !is_readable(RACKET_ZIP_FILE)) {
|
||||
throw new RuntimeException('racket.zip ontbreekt of is niet leesbaar: ' . RACKET_ZIP_FILE);
|
||||
}
|
||||
|
||||
racket_zip_ensure_data_dir();
|
||||
racket_zip_remove_old_parts();
|
||||
|
||||
$in = fopen(RACKET_ZIP_FILE, 'rb');
|
||||
|
||||
if ($in === false) {
|
||||
throw new RuntimeException('Kan racket.zip niet openen.');
|
||||
}
|
||||
|
||||
$parts = array();
|
||||
$nr = 1;
|
||||
$totalBinaryBytes = 0;
|
||||
|
||||
while (!feof($in)) {
|
||||
$buf = fread($in, $binaryChunkBytes);
|
||||
|
||||
if ($buf === false) {
|
||||
fclose($in);
|
||||
throw new RuntimeException('Fout bij lezen van racket.zip.');
|
||||
}
|
||||
|
||||
if ($buf === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$name = racket_zip_part_name($nr);
|
||||
$file = RACKET_ZIP_DATA_DIR . '/' . $name;
|
||||
|
||||
if (file_put_contents($file, $buf, LOCK_EX) === false) {
|
||||
fclose($in);
|
||||
throw new RuntimeException('Kan part niet schrijven: ' . $file);
|
||||
}
|
||||
|
||||
$n = sprintf('%06d', $nr);
|
||||
$binaryBytes = strlen($buf);
|
||||
|
||||
$parts[] = array(
|
||||
'number' => $n,
|
||||
'name' => $name,
|
||||
'file' => $file,
|
||||
'size' => $binaryBytes,
|
||||
);
|
||||
|
||||
$totalBinaryBytes += $binaryBytes;
|
||||
$nr++;
|
||||
}
|
||||
|
||||
fclose($in);
|
||||
|
||||
if (count($parts) === 0) {
|
||||
throw new RuntimeException('racket.zip lijkt leeg te zijn.');
|
||||
}
|
||||
|
||||
$manifest = array(
|
||||
'source_file' => RACKET_ZIP_FILE,
|
||||
'source_bytes' => filesize(RACKET_ZIP_FILE),
|
||||
'source_mtime' => filemtime(RACKET_ZIP_FILE),
|
||||
'source_sha256' => hash_file('sha256', RACKET_ZIP_FILE),
|
||||
'max_base64_kib' => (int)$maxBase64Kib,
|
||||
'max_base64_bytes' => $maxBase64Bytes,
|
||||
'binary_chunk_bytes' => $binaryChunkBytes,
|
||||
'part_count' => count($parts),
|
||||
'total_binary_bytes' => $totalBinaryBytes,
|
||||
'parts' => $parts,
|
||||
'created_at' => gmdate('c'),
|
||||
);
|
||||
|
||||
if (file_put_contents(
|
||||
RACKET_ZIP_MANIFEST_FILE,
|
||||
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n",
|
||||
LOCK_EX
|
||||
) === false) {
|
||||
throw new RuntimeException('Kan racket.zip manifest niet schrijven: ' . RACKET_ZIP_MANIFEST_FILE);
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
function load_racket_zip_parts_manifest()
|
||||
{
|
||||
if (!is_file(RACKET_ZIP_MANIFEST_FILE) || !is_readable(RACKET_ZIP_MANIFEST_FILE)) {
|
||||
throw new RuntimeException('racket.zip parts-manifest ontbreekt: ' . RACKET_ZIP_MANIFEST_FILE);
|
||||
}
|
||||
|
||||
$raw = file_get_contents(RACKET_ZIP_MANIFEST_FILE);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('Kan racket.zip parts-manifest niet lezen: ' . RACKET_ZIP_MANIFEST_FILE);
|
||||
}
|
||||
|
||||
$manifest = json_decode($raw, true);
|
||||
|
||||
if (!is_array($manifest)) {
|
||||
throw new RuntimeException('racket.zip parts-manifest is geen geldige JSON: ' . RACKET_ZIP_MANIFEST_FILE);
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
function racket_zip_parts_current($manifest, $maxBase64Kib)
|
||||
{
|
||||
if (!is_array($manifest)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int)($manifest['max_base64_kib'] ?? 0) !== (int)$maxBase64Kib) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_file(RACKET_ZIP_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int)($manifest['source_bytes'] ?? -1) !== (int)filesize(RACKET_ZIP_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int)($manifest['source_mtime'] ?? -1) !== (int)filemtime(RACKET_ZIP_FILE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (($manifest['parts'] ?? array()) as $part) {
|
||||
$number = (string)($part['number'] ?? '');
|
||||
$file = racket_zip_part_file($number);
|
||||
|
||||
if (!preg_match('/^[0-9]{6}$/', $number) || !is_file($file) || !is_readable($file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return count($manifest['parts'] ?? array()) > 0;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
/*
|
||||
* Simpele Racket bootstrap.
|
||||
*
|
||||
* .htaccess:
|
||||
*
|
||||
* FallbackResource /index.php
|
||||
*
|
||||
* Bestanden:
|
||||
*
|
||||
* config/racket.zip
|
||||
* data/ bevat vooraf gemaakte parts
|
||||
*
|
||||
* Routes:
|
||||
*
|
||||
* /bootstrap-racket?next=...
|
||||
* Leest de vooraf gemaakte parts in data/ en toont een HTML-index
|
||||
* met links.
|
||||
*
|
||||
* /bootstrap-racket-part?n=000001&next=...
|
||||
* Geeft text/plain met base64 van precies één binair part.
|
||||
*
|
||||
* Regels:
|
||||
*
|
||||
* - Indexen/lijsten zijn HTML.
|
||||
* - Payload/downloads zijn text/plain.
|
||||
* - Payload/downloads zijn altijd base64.
|
||||
* - Geen .bin, .zip of bestandsnaam in download-URL’s.
|
||||
* - next=... is alleen cache-busting.
|
||||
* - Per gegenereerde HTML-pagina wordt één next-id gemaakt en voor alle
|
||||
* links op die pagina gebruikt.
|
||||
*/
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/nexttoken.php';
|
||||
require_once __DIR__ . '/base64config.php';
|
||||
require_once __DIR__ . '/racketzip.php';
|
||||
|
||||
$TOKENS = new NextTokenStore(__DIR__ . '/data/racket-sandbox.sqlite');
|
||||
|
||||
@set_time_limit(300);
|
||||
ignore_user_abort(false);
|
||||
|
||||
$chunkConfig = load_base64_chunk_config();
|
||||
$racketZipMaxBase64Kb = (int)($chunkConfig['racket_zip_max_base64_kb'] ?? 8192);
|
||||
|
||||
if ($racketZipMaxBase64Kb < 1) {
|
||||
$racketZipMaxBase64Kb = 1;
|
||||
}
|
||||
|
||||
define('RACKET_ZIP_MAX_BASE64_KB', $racketZipMaxBase64Kb);
|
||||
define('RACKET_ZIP_MAX_BASE64_BYTES', RACKET_ZIP_MAX_BASE64_KB * 1024);
|
||||
define('RACKET_ZIP_BINARY_CHUNK_BYTES', racket_zip_binary_chunk_bytes_for_base64_kib(RACKET_ZIP_MAX_BASE64_KB));
|
||||
|
||||
if (RACKET_ZIP_BINARY_CHUNK_BYTES < 3) {
|
||||
fail_html('Ongeldige racket.zip base64 chunk-instelling.');
|
||||
}
|
||||
|
||||
/*
|
||||
* Eén next-id voor alle URLs die deze request genereert.
|
||||
*/
|
||||
$NEXT_ID = $TOKENS->create();
|
||||
|
||||
function path_only()
|
||||
{
|
||||
$p = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
$p = '/' . trim($p ?: '/', '/');
|
||||
return $p === '/' ? '/' : $p;
|
||||
}
|
||||
|
||||
function scheme()
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||
return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0]));
|
||||
}
|
||||
|
||||
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
}
|
||||
|
||||
function host_name()
|
||||
{
|
||||
return $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
}
|
||||
|
||||
function make_url($path, $query = array())
|
||||
{
|
||||
global $NEXT_ID;
|
||||
|
||||
$query['next'] = $NEXT_ID;
|
||||
|
||||
return scheme() . '://' . host_name() . $path . '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function html_response($html, $status = 200)
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
function text_response($text, $status = 200)
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/plain; charset=us-ascii');
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
echo $text;
|
||||
|
||||
if ($text === '' || substr($text, -1) !== "\n") {
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
function fail_html($message, $status = 500)
|
||||
{
|
||||
html_response(
|
||||
'<!doctype html>' .
|
||||
'<html><head><meta charset="utf-8"><title>Error</title></head>' .
|
||||
'<body><h1>Error</h1><pre>' . h($message) . '</pre></body></html>',
|
||||
$status
|
||||
);
|
||||
}
|
||||
|
||||
function fail_text($message, $status = 500)
|
||||
{
|
||||
text_response("error: " . $message . "\n", $status);
|
||||
}
|
||||
|
||||
function load_parts()
|
||||
{
|
||||
try {
|
||||
$manifest = load_racket_zip_parts_manifest();
|
||||
} catch (Throwable $e) {
|
||||
fail_html($e->getMessage() . "\n\nSla de configuratiepagina op om racket.zip in parts te verdelen.");
|
||||
}
|
||||
|
||||
if (!racket_zip_parts_current($manifest, RACKET_ZIP_MAX_BASE64_KB)) {
|
||||
fail_html(
|
||||
"racket.zip parts ontbreken of passen niet bij de huidige chunk-grootte.\n\n" .
|
||||
"Sla de configuratiepagina op om de parts opnieuw te maken."
|
||||
);
|
||||
}
|
||||
|
||||
$parts = array();
|
||||
|
||||
foreach (($manifest['parts'] ?? array()) as $part) {
|
||||
$number = (string)($part['number'] ?? '');
|
||||
$parts[] = array(
|
||||
'number' => $number,
|
||||
'name' => (string)($part['name'] ?? racket_zip_part_name((int)$number)),
|
||||
'size' => (int)($part['size'] ?? 0),
|
||||
'url' => make_url('/bootstrap-racket-part', array('n' => $number)),
|
||||
);
|
||||
}
|
||||
|
||||
$manifest['parts'] = $parts;
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
function serve_bootstrap()
|
||||
{
|
||||
global $NEXT_ID;
|
||||
|
||||
$manifest = load_parts();
|
||||
$parts = $manifest['parts'];
|
||||
|
||||
$rows = '';
|
||||
|
||||
foreach ($parts as $i => $part) {
|
||||
$rows .=
|
||||
'<tr>' .
|
||||
'<td>' . h((string)($i + 1)) . '</td>' .
|
||||
'<td>' . h($part['number']) . '</td>' .
|
||||
'<td>' . h((string)$part['size']) . '</td>' .
|
||||
'<td><a href="' . h($part['url']) . '">' . h($part['url']) . '</a></td>' .
|
||||
'</tr>' . "\n";
|
||||
}
|
||||
|
||||
$zipSize = (int)($manifest['source_bytes'] ?? 0);
|
||||
|
||||
$pkg_url = make_url('/racket-pkg-index');
|
||||
|
||||
html_response('<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Racket bootstrap</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body class="simple-doc">
|
||||
|
||||
<h1>Racket bootstrap</h1>
|
||||
|
||||
<p>
|
||||
<code>racket.zip</code> is vooraf via de configuratiepagina gesplitst naar
|
||||
parts in de map <code>data</code>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Package index: <a href="' . $pkg_url . '"> Racket package index</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Bronbestand: <code>config/racket.zip</code><br>
|
||||
Bronbestand bytes: <code>' . h((string)$zipSize) . '</code><br>
|
||||
Maximale base64 part-grootte: <code>' . h((string)RACKET_ZIP_MAX_BASE64_KB) . '</code> KiB (<code>' . h((string)RACKET_ZIP_MAX_BASE64_BYTES) . '</code> bytes)<br>
|
||||
Binaire chunk-grootte: <code>' . h((string)RACKET_ZIP_BINARY_CHUNK_BYTES) . '</code> bytes<br>
|
||||
Aantal parts: <code>' . h((string)count($parts)) . '</code><br>
|
||||
Parts gemaakt op: <code>' . h((string)($manifest['created_at'] ?? '')) . '</code><br>
|
||||
next-id voor alle links op deze pagina: <code>' . h($NEXT_ID) . '</code>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Elke link hieronder geeft <strong>text/plain</strong> met de
|
||||
<strong>base64-representatie van één binair part</strong>.
|
||||
De URL bevat alleen een nummer, geen bestandsnaam en geen extensie.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>partnummer</th>
|
||||
<th>bytes</th>
|
||||
<th>base64 text/plain URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
' . $rows . '
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Reconstructie in de sandbox</h2>
|
||||
|
||||
<p>
|
||||
Decodeer ieder base64-part afzonderlijk naar een binair part. Plak daarna de
|
||||
binaire parts in numerieke volgorde aan elkaar.
|
||||
</p>
|
||||
|
||||
<pre>
|
||||
base64 -d part-000001.txt > part-000001
|
||||
base64 -d part-000002.txt > part-000002
|
||||
base64 -d part-000003.txt > part-000003
|
||||
# enzovoort
|
||||
|
||||
cat part-* > racket.zip
|
||||
unzip racket.zip -d /tmp/racket
|
||||
</pre>
|
||||
|
||||
</body>
|
||||
</html>');
|
||||
}
|
||||
|
||||
function serve_part()
|
||||
{
|
||||
$n = $_GET['n'] ?? '';
|
||||
|
||||
if (!is_string($n) || !preg_match('/^[0-9]{6}$/', $n)) {
|
||||
fail_text('ongeldig partnummer; gebruik bijvoorbeeld n=000001', 400);
|
||||
}
|
||||
|
||||
$file = racket_zip_part_file($n);
|
||||
|
||||
if (!is_file($file) || !is_readable($file)) {
|
||||
fail_text('part ontbreekt of is niet leesbaar: ' . $n, 404);
|
||||
}
|
||||
|
||||
$bin = file_get_contents($file);
|
||||
|
||||
if ($bin === false) {
|
||||
fail_text('kan part niet lezen: ' . $n, 500);
|
||||
}
|
||||
|
||||
text_response(base64_encode($bin));
|
||||
}
|
||||
|
||||
$path = path_only();
|
||||
|
||||
if ($path === '/' || $path === '/index.php') {
|
||||
header('Location: ' . make_url('/bootstrap-racket'), true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($path === '/bootstrap-racket') {
|
||||
$TOKENS->check_valid_next('html');
|
||||
serve_bootstrap();
|
||||
}
|
||||
|
||||
if ($path === '/bootstrap-racket-part') {
|
||||
$TOKENS->check_valid_next('text');
|
||||
serve_part();
|
||||
}
|
||||
|
||||
fail_html('Onbekende route: ' . $path, 404);
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/*
|
||||
* rktpkgs.php
|
||||
*
|
||||
* Maakt een volledige HTML-index van de Racket package catalogus.
|
||||
*
|
||||
* Bron:
|
||||
* https://pkgs.racket-lang.org/pkgs-all
|
||||
*
|
||||
* Output:
|
||||
* Alleen HTML.
|
||||
*
|
||||
* Package-links:
|
||||
* /package?name=<pkg-name>&next=<id>
|
||||
*
|
||||
* Regels:
|
||||
* - Geen filterveld.
|
||||
* - Geen git-adressen tonen.
|
||||
* - Geen package-details tonen.
|
||||
* - Geen package-downloads implementeren in dit script.
|
||||
* - De package-naam zelf is de link.
|
||||
* - Eén next-id per gegenereerde HTML-pagina.
|
||||
*
|
||||
* .htaccess:
|
||||
*
|
||||
* Options -MultiViews
|
||||
* RewriteEngine On
|
||||
* RewriteRule ^racket-pkg-index$ rktpkgs.php [L,QSA]
|
||||
*/
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/nexttoken.php';
|
||||
require_once __DIR__ . '/lib/catalog-http.php';
|
||||
require_once __DIR__ . '/lib/racket-data.php';
|
||||
|
||||
$TOKENS = new NextTokenStore(__DIR__ . '/data/racket-sandbox.sqlite');
|
||||
|
||||
@set_time_limit(180);
|
||||
ignore_user_abort(false);
|
||||
|
||||
define('CATALOG_PKGS_ALL', 'https://pkgs.racket-lang.org/pkgs-all');
|
||||
|
||||
define('CACHE_DIR', __DIR__ . '/data');
|
||||
define('CACHE_FILE', CACHE_DIR . '/pkgs-all.rktd');
|
||||
define('CACHE_META_FILE', CACHE_DIR . '/pkgs-all.meta.json');
|
||||
define('CACHE_TTL', 3600);
|
||||
|
||||
/*
|
||||
* Eén next-id voor alle package-links op deze pagina.
|
||||
*/
|
||||
$NEXT_ID = $TOKENS->create();
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function current_scheme()
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||
return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0]));
|
||||
}
|
||||
|
||||
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
}
|
||||
|
||||
function current_host()
|
||||
{
|
||||
return $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
}
|
||||
|
||||
function make_url($path, $query = array())
|
||||
{
|
||||
global $NEXT_ID;
|
||||
|
||||
$query['next'] = $NEXT_ID;
|
||||
|
||||
return current_scheme() . '://' . current_host() . $path . '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
function html_response($html, $status = 200)
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
function fail_html($message, $status = 500)
|
||||
{
|
||||
html_response(
|
||||
'<!doctype html>' .
|
||||
'<html><head><meta charset="utf-8"><title>Error</title></head>' .
|
||||
'<body><h1>Error</h1><pre>' . h($message) . '</pre></body></html>',
|
||||
$status
|
||||
);
|
||||
}
|
||||
|
||||
function get_pkgs_all_text()
|
||||
{
|
||||
try {
|
||||
return catalog_http_fetch_cached(
|
||||
CATALOG_PKGS_ALL,
|
||||
CACHE_FILE,
|
||||
CACHE_META_FILE,
|
||||
CACHE_TTL,
|
||||
'rktsndbx-rktpkgs/1.0',
|
||||
180
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
fail_html($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function serve_index()
|
||||
{
|
||||
global $NEXT_ID;
|
||||
|
||||
$text = get_pkgs_all_text();
|
||||
$names = rktd_extract_top_level_package_names($text);
|
||||
|
||||
if (count($names) === 0) {
|
||||
fail_html('Geen package-namen gevonden in pkgs-all.');
|
||||
}
|
||||
|
||||
$rows = '';
|
||||
|
||||
foreach ($names as $i => $name) {
|
||||
$url = make_url('/package', array('name' => $name));
|
||||
|
||||
$rows .=
|
||||
'<tr id="pkg-' . h($name) . '">' .
|
||||
'<td>' . h((string)($i + 1)) . '</td>' .
|
||||
'<td class="pkg-name">' .
|
||||
'<a class="pkg-link" href="' . h($url) . '">' . h($name) . '</a>' .
|
||||
'</td>' .
|
||||
'</tr>' . "\n";
|
||||
}
|
||||
|
||||
html_response('<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Racket package index</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body class="simple-doc package-index-page">
|
||||
|
||||
<h1>Racket package index</h1>
|
||||
|
||||
<p>
|
||||
Volledige HTML-index van de Racket package catalogus op basis van
|
||||
<code>pkgs-all</code>. De package-naam is de ophaallink via
|
||||
<code>rktsndbx.dijkewijk.nl</code>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Aantal packages: <code>' . h((string)count($names)) . '</code><br>
|
||||
next-id voor alle package-links op deze pagina:
|
||||
<code>' . h($NEXT_ID) . '</code>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>package</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
' . $rows . '
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>');
|
||||
}
|
||||
|
||||
$TOKENS->check_valid_next('html');
|
||||
serve_index();
|
||||
+932
@@ -0,0 +1,932 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Verdana, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
color: #242424;
|
||||
background: #e7e7e7;
|
||||
}
|
||||
|
||||
body.simple-doc {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
margin: 2rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
body.simple-doc table {
|
||||
margin: 0.75rem 0 1.5rem 0;
|
||||
}
|
||||
|
||||
body.simple-doc pre {
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body.login-page form {
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
body.login-page .login-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
max-width: 58rem;
|
||||
}
|
||||
|
||||
body.login-page .login-panel {
|
||||
flex: 1 1 28rem;
|
||||
}
|
||||
|
||||
body.login-page .login-request-panel {
|
||||
flex: 0 1 22rem;
|
||||
border-left: 1px solid #ddd;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
body.login-page .login-request-panel h2 {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
body.login-page label {
|
||||
display: block;
|
||||
margin: 0.7rem 0;
|
||||
}
|
||||
|
||||
body.login-page input[type=email],
|
||||
body.login-page input[type=password] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.login-page button {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body.login-page .login-layout {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
body.login-page .login-request-panel {
|
||||
border-left: none;
|
||||
border-top: 1px solid #ddd;
|
||||
padding-left: 0;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pkg-name,
|
||||
.pkg-link {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.page-titlebar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid #c8c8c8;
|
||||
background: #f4f4f4;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset;
|
||||
}
|
||||
|
||||
.page-titlebar h1 {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
color: #303030;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-nav a,
|
||||
.header-nav strong,
|
||||
.nav-user {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-separator {
|
||||
color: #8a8a8a;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-nav a,
|
||||
.header-nav strong {
|
||||
display: inline-block;
|
||||
padding: 0.32rem 0.65rem;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
background: #ededed;
|
||||
color: #252525;
|
||||
text-decoration: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.header-nav a:hover,
|
||||
.header-nav a:focus {
|
||||
background: #dedede;
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.header-nav strong {
|
||||
background: #555;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
color: #454545;
|
||||
}
|
||||
|
||||
.header-language-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-action-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-action-form button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-language-form label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-language-form select {
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.8rem;
|
||||
margin: 0.75rem 0 0 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
input[type=password],
|
||||
input[type=number] {
|
||||
width: 28rem;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.copy-box {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.generated-link-row {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.bootstrap-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18rem, 0.9fr) minmax(22rem, 1.1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.generated-link-row a {
|
||||
max-width: min(72vw, 70rem);
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid #ccc;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bootstrap-prompt-tool {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
max-width: min(76vw, 72rem);
|
||||
}
|
||||
|
||||
.bootstrap-prompt-tool select,
|
||||
.bootstrap-prompt-tool textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bootstrap-prompt-tool textarea {
|
||||
min-height: 14rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.bootstrap-result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-form-grid,
|
||||
.user-row-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(11rem, 1.2fr) minmax(14rem, 1.4fr) auto auto auto;
|
||||
align-items: end;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.admin-form-grid input[type=text],
|
||||
.admin-form-grid input[type=email],
|
||||
.admin-form-grid input[type=password],
|
||||
.admin-form-grid input[type=number],
|
||||
.user-row-form input[type=text],
|
||||
.user-row-form input[type=email],
|
||||
.user-row-actions input[type=password] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.55rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.user-row-actions form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #c8c8c8;
|
||||
padding: 0.75rem;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0.4rem 0 0.6rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 6px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
background: #ececec;
|
||||
}
|
||||
|
||||
.token-valid td {
|
||||
background: #edf8ed;
|
||||
color: #176127;
|
||||
}
|
||||
|
||||
.token-expired td {
|
||||
background: #fff0f0;
|
||||
color: #9d1c1c;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
textarea {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type=text],
|
||||
select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0.15rem 0.15rem 0.15rem 0;
|
||||
padding: 0.32rem 0.65rem;
|
||||
border: 1px solid #999;
|
||||
border-radius: 4px;
|
||||
background: #e9e9e9;
|
||||
color: #242424;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
button:focus {
|
||||
background: #dcdcdc;
|
||||
border-color: #777;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.5rem;
|
||||
background: #eef8ee;
|
||||
border: 1px solid #9c9;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.5rem;
|
||||
background: #fff0f0;
|
||||
border: 1px solid #c99;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.small {
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions form,
|
||||
.version-actions form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.language-label {
|
||||
font-weight: bold;
|
||||
margin-right: 0.45rem;
|
||||
}
|
||||
|
||||
.language-switch a,
|
||||
.language-switch .active-language {
|
||||
display: inline-block;
|
||||
margin: 0.15rem 0.35rem 0.15rem 0;
|
||||
padding: 0.24rem 0.55rem;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
background: #ededed;
|
||||
color: #252525;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.language-switch .active-language {
|
||||
background: #555;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Prompt workbench */
|
||||
|
||||
.prompt-workbench {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.prompt-sidebar,
|
||||
.prompt-viewer {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.prompt-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.prompt-tab {
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prompt-tab.active {
|
||||
background: #555;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prompt-tab[data-tab="defaults"].active {
|
||||
border-color: #8a6a1f;
|
||||
}
|
||||
|
||||
.prompt-tab-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-tab-panel.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-actions button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.default-admin-notice {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid #c7a949;
|
||||
border-left: 4px solid #8a6a1f;
|
||||
background: #fff8dc;
|
||||
color: #3d3214;
|
||||
}
|
||||
|
||||
.default-admin-notice strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.default-admin-notice span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prompt-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.45rem;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.prompt-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.prompt-list-item form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prompt-list-item form button {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt-select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
.prompt-select.selected {
|
||||
border-color: #555;
|
||||
background: #ececec;
|
||||
box-shadow: inset 3px 0 0 #555;
|
||||
}
|
||||
|
||||
.default-prompt-item .prompt-select {
|
||||
border-left-color: #8a6a1f;
|
||||
box-shadow: inset 3px 0 0 #c7a949;
|
||||
}
|
||||
|
||||
.default-prompt-item .prompt-select.selected {
|
||||
border-color: #8a6a1f;
|
||||
background: #fff8dc;
|
||||
box-shadow: inset 4px 0 0 #8a6a1f;
|
||||
}
|
||||
|
||||
.prompt-name,
|
||||
.prompt-subline {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prompt-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.prompt-subline {
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.create-drawer {
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.create-drawer > summary {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.create-drawer form {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.create-drawer input[type=text],
|
||||
.create-drawer select,
|
||||
.create-drawer textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.viewer-empty {
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prompt-viewer {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.prompt-viewer.is-default-prompt {
|
||||
border-color: #c7a949;
|
||||
}
|
||||
|
||||
.prompt-viewer.is-default-prompt .viewer-header {
|
||||
border-bottom-color: #c7a949;
|
||||
}
|
||||
|
||||
.prompt-viewer.is-default-prompt .viewer-title::after {
|
||||
content: " default";
|
||||
margin-left: 0.45rem;
|
||||
padding: 0.08rem 0.35rem;
|
||||
border: 1px solid #c7a949;
|
||||
border-radius: 4px;
|
||||
background: #fff8dc;
|
||||
color: #5a4515;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.prompt-viewer.is-empty .viewer-shell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prompt-viewer:not(.is-empty) .viewer-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding-bottom: 0.65rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.viewer-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
margin: 0.7rem 0 0 0;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid #ccc;
|
||||
background: #fff;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#promptModalForm:not(.can-edit) .modal-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#promptModalForm.read-only-default .modal-title::after {
|
||||
content: " view only";
|
||||
color: #777;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Legacy modal classes, now reused by the inline prompt viewer. */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-backdrop.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.prompt-modal {
|
||||
width: min(96vw, 1500px);
|
||||
height: min(94vh, 980px);
|
||||
background: white;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-backdrop.is-default-prompt .prompt-modal {
|
||||
border-color: #8a6a1f;
|
||||
}
|
||||
|
||||
.modal-backdrop.is-default-prompt .modal-header {
|
||||
background: #fff8dc;
|
||||
}
|
||||
|
||||
.prompt-modal > form {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-toolbar,
|
||||
.modal-footer {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid #ddd;
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edit-pane,
|
||||
.version-pane {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.content-label {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edit-pane textarea {
|
||||
display: block;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.version-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.5rem;
|
||||
background: #fafafa;
|
||||
white-space: pre-wrap;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.modal-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.modal-form-grid label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-form-grid input[type=text],
|
||||
.modal-form-grid select,
|
||||
.default-key-row input[type=text] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.default-key-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: block;
|
||||
padding: 0 0.15rem;
|
||||
min-height: 1.25em;
|
||||
}
|
||||
|
||||
.diff-same {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.diff-added {
|
||||
color: #064f16;
|
||||
background: #e7f7e7;
|
||||
}
|
||||
|
||||
.diff-deleted {
|
||||
color: #8a0000;
|
||||
background: #ffe8e8;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.diff-changed {
|
||||
color: #5c2380;
|
||||
background: #f2e6ff;
|
||||
}
|
||||
|
||||
.diff-muted {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-titlebar {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.prompt-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(220px, 38vh) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.prompt-modal {
|
||||
width: 98vw;
|
||||
height: 96vh;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-panel > summary {
|
||||
cursor: pointer;
|
||||
font-size: 1.15rem;
|
||||
font-weight: bold;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.collapsible-panel > summary .small {
|
||||
font-weight: normal;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt-preview > summary {
|
||||
cursor: pointer;
|
||||
color: #0645ad;
|
||||
}
|
||||
|
||||
.prompt-preview pre {
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5rem;
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
/*
|
||||
* users.php
|
||||
*
|
||||
* Admin user management.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/header.php';
|
||||
require_once __DIR__ . '/languagestore.php';
|
||||
require_once __DIR__ . '/usersettings.php';
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
ini_set('log_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$DB_FILE = __DIR__ . '/data/racket-sandbox.sqlite';
|
||||
|
||||
$auth = new RacketSandboxAuth($DB_FILE);
|
||||
$languageStore = new LanguageStore($DB_FILE);
|
||||
$userSettings = new UserSettingsStore($DB_FILE);
|
||||
$currentUser = $auth->requireAdminHtml();
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
|
||||
function h($s)
|
||||
{
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function t($key, $fallback = null)
|
||||
{
|
||||
global $languageStore, $language;
|
||||
|
||||
return $languageStore->translate($key, $language, $fallback);
|
||||
}
|
||||
|
||||
function post_value($name, $default = '')
|
||||
{
|
||||
return $_POST[$name] ?? $default;
|
||||
}
|
||||
|
||||
function post_bool($name)
|
||||
{
|
||||
return isset($_POST[$name]) && $_POST[$name] === '1';
|
||||
}
|
||||
|
||||
function resolve_user_language($userSettings, $userId, $allowedLanguages)
|
||||
{
|
||||
$language = isset($_GET['lang'])
|
||||
? (string)$_GET['lang']
|
||||
: (string)$userSettings->get($userId, 'language', 'en');
|
||||
|
||||
if (!in_array($language, $allowedLanguages, true)) {
|
||||
$language = 'en';
|
||||
}
|
||||
|
||||
$userSettings->set($userId, 'language', $language);
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
function fmt_time($ts)
|
||||
{
|
||||
if ($ts === null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', (int)$ts);
|
||||
}
|
||||
|
||||
$language = resolve_user_language(
|
||||
$userSettings,
|
||||
$currentUser->id(),
|
||||
$languageStore->supportedLanguages()
|
||||
);
|
||||
|
||||
$languageStore->seedDefaults(array(
|
||||
'app.title' => array('en' => 'Racket sandbox', 'nl' => 'Racket sandbox'),
|
||||
'app.manage_prompts' => array('en' => 'Manage prompts', 'nl' => 'Prompts beheren'),
|
||||
'app.user_management' => array('en' => 'User management', 'nl' => 'Gebruikersbeheer'),
|
||||
'app.logout' => array('en' => 'Logout', 'nl' => 'Uitloggen'),
|
||||
'app.language' => array('en' => 'Language', 'nl' => 'Taal'),
|
||||
'app.logged_in_as' => array('en' => 'Logged in as:', 'nl' => 'Ingelogd als:'),
|
||||
'app.admin' => array('en' => 'Admin', 'nl' => 'Admin'),
|
||||
'app.enabled' => array('en' => 'Enabled', 'nl' => 'Ingeschakeld'),
|
||||
'app.full_name' => array('en' => 'Full name', 'nl' => 'Volledige naam'),
|
||||
'app.email' => array('en' => 'Email', 'nl' => 'E-mail'),
|
||||
'app.password' => array('en' => 'Password', 'nl' => 'Wachtwoord'),
|
||||
'app.new_password' => array('en' => 'New password', 'nl' => 'Nieuw wachtwoord'),
|
||||
'app.created' => array('en' => 'Created', 'nl' => 'Gemaakt'),
|
||||
'app.last_login' => array('en' => 'Last login', 'nl' => 'Laatste login'),
|
||||
'app.actions' => array('en' => 'Actions', 'nl' => 'Acties'),
|
||||
'app.create_user' => array('en' => 'Create user', 'nl' => 'Gebruiker aanmaken'),
|
||||
'app.update_user' => array('en' => 'Update user', 'nl' => 'Gebruiker aanpassen'),
|
||||
'app.change_password' => array('en' => 'Change password', 'nl' => 'Wachtwoord wijzigen'),
|
||||
'app.delete_user' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'),
|
||||
'app.delete_user_confirm' => array('en' => 'Delete user', 'nl' => 'Gebruiker verwijderen'),
|
||||
'app.cannot_delete_self' => array('en' => 'You cannot delete your own account.', 'nl' => 'Je kunt je eigen account niet verwijderen.'),
|
||||
'app.cannot_disable_self' => array('en' => 'You cannot disable your own account.', 'nl' => 'Je kunt je eigen account niet uitschakelen.'),
|
||||
'app.cannot_remove_own_admin' => array('en' => 'You cannot remove your own admin rights.', 'nl' => 'Je kunt je eigen adminrechten niet verwijderen.'),
|
||||
'app.user_created' => array('en' => 'User created.', 'nl' => 'Gebruiker aangemaakt.'),
|
||||
'app.user_updated' => array('en' => 'User updated.', 'nl' => 'Gebruiker aangepast.'),
|
||||
'app.password_changed' => array('en' => 'Password changed.', 'nl' => 'Wachtwoord gewijzigd.'),
|
||||
'app.user_deleted' => array('en' => 'User deleted.', 'nl' => 'Gebruiker verwijderd.'),
|
||||
'app.back_to_sandbox' => array('en' => 'Back to Racket sandbox', 'nl' => 'Terug naar Racket sandbox'),
|
||||
'app.configuration' => array('en' => 'Configuration', 'nl' => 'Configuratie'),
|
||||
));
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = post_value('action');
|
||||
|
||||
try {
|
||||
if ($action === 'logout') {
|
||||
$auth->logout();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
} elseif ($action === 'create_user') {
|
||||
$auth->createUser(
|
||||
post_value('email'),
|
||||
post_value('full_name'),
|
||||
post_value('password'),
|
||||
post_bool('is_admin'),
|
||||
post_bool('is_enabled')
|
||||
);
|
||||
$message = t('app.user_created', 'User created.');
|
||||
} elseif ($action === 'update_user') {
|
||||
$userId = (int)post_value('user_id');
|
||||
$isAdmin = post_bool('is_admin');
|
||||
$isEnabled = post_bool('is_enabled');
|
||||
|
||||
if ($userId === $currentUser->id() && !$isAdmin) {
|
||||
throw new Exception(t('app.cannot_remove_own_admin', 'You cannot remove your own admin rights.'));
|
||||
}
|
||||
|
||||
if ($userId === $currentUser->id() && !$isEnabled) {
|
||||
throw new Exception(t('app.cannot_disable_self', 'You cannot disable your own account.'));
|
||||
}
|
||||
|
||||
$auth->updateUser($userId, post_value('email'), post_value('full_name'));
|
||||
$auth->setAdmin($userId, $isAdmin);
|
||||
$auth->setEnabled($userId, $isEnabled);
|
||||
$message = t('app.user_updated', 'User updated.');
|
||||
} elseif ($action === 'set_password') {
|
||||
$auth->setPassword(post_value('email'), post_value('password'));
|
||||
$message = t('app.password_changed', 'Password changed.');
|
||||
} elseif ($action === 'delete_user') {
|
||||
$userId = (int)post_value('user_id');
|
||||
|
||||
if ($userId === $currentUser->id()) {
|
||||
throw new Exception(t('app.cannot_delete_self', 'You cannot delete your own account.'));
|
||||
}
|
||||
|
||||
$auth->deleteUser($userId);
|
||||
$message = t('app.user_deleted', 'User deleted.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$users = $auth->listUsers();
|
||||
$headerLanguages = array();
|
||||
|
||||
foreach ($languageStore->supportedLanguages() as $lang) {
|
||||
$headerLanguages[$lang] = $languageStore->languageLabel($lang);
|
||||
}
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?= h($language) ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?= h(t('app.user_management', 'User management')) ?></title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<?php
|
||||
render_app_header(array(
|
||||
'title' => t('app.user_management', 'User management'),
|
||||
'nav_items' => array(
|
||||
array('label' => t('app.back_to_sandbox', 'Back to Racket sandbox'), 'url' => '/?lang=' . rawurlencode($language)),
|
||||
array(
|
||||
'label' => t('app.manage_prompts', 'Manage prompts'),
|
||||
'url' => '/prompts?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
),
|
||||
array(
|
||||
'label' => t('app.user_management', 'User management'),
|
||||
'url' => '/users?lang=' . rawurlencode($language),
|
||||
'active' => true,
|
||||
'separator_before' => true,
|
||||
),
|
||||
array(
|
||||
'label' => t('app.configuration', 'Configuration'),
|
||||
'url' => '/admin-config?lang=' . rawurlencode($language),
|
||||
'separator_before' => true,
|
||||
),
|
||||
),
|
||||
'user' => $currentUser,
|
||||
'user_prefix' => t('app.logged_in_as', 'Logged in as:'),
|
||||
'admin_label' => t('app.admin', 'Admin'),
|
||||
'language_label' => t('app.language', 'Language'),
|
||||
'language' => $language,
|
||||
'languages' => $headerLanguages,
|
||||
'language_action' => '/users',
|
||||
'logout_action' => '/users?lang=' . rawurlencode($language),
|
||||
'logout_label' => t('app.logout', 'Logout'),
|
||||
'message' => $message,
|
||||
'error' => $error,
|
||||
));
|
||||
?>
|
||||
|
||||
<main class="page-main dashboard-main">
|
||||
|
||||
<section class="panel">
|
||||
<h2><?= h(t('app.create_user', 'Create user')) ?></h2>
|
||||
|
||||
<form method="post" action="/users?lang=<?= h($language) ?>" class="admin-form-grid">
|
||||
<input type="hidden" name="action" value="create_user">
|
||||
<label><?= h(t('app.full_name', 'Full name')) ?><br><input type="text" name="full_name" required></label>
|
||||
<label><?= h(t('app.email', 'Email')) ?><br><input type="email" name="email" required></label>
|
||||
<label><?= h(t('app.password', 'Password')) ?><br><input type="password" name="password" autocomplete="new-password" required></label>
|
||||
<label><input type="checkbox" name="is_admin" value="1"> <?= h(t('app.admin', 'Admin')) ?></label>
|
||||
<label><input type="checkbox" name="is_enabled" value="1" checked> <?= h(t('app.enabled', 'Enabled')) ?></label>
|
||||
<button type="submit"><?= h(t('app.create_user', 'Create user')) ?></button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2><?= h(t('app.user_management', 'User management')) ?></h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= h(t('app.full_name', 'Full name')) ?></th>
|
||||
<th><?= h(t('app.email', 'Email')) ?></th>
|
||||
<th><?= h(t('app.admin', 'Admin')) ?></th>
|
||||
<th><?= h(t('app.enabled', 'Enabled')) ?></th>
|
||||
<th><?= h(t('app.created', 'Created')) ?></th>
|
||||
<th><?= h(t('app.last_login', 'Last login')) ?></th>
|
||||
<th><?= h(t('app.actions', 'Actions')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $managedUser): ?>
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<form method="post" action="/users?lang=<?= h($language) ?>" class="user-row-form">
|
||||
<input type="hidden" name="action" value="update_user">
|
||||
<input type="hidden" name="user_id" value="<?= h($managedUser->id()) ?>">
|
||||
|
||||
<label><?= h(t('app.full_name', 'Full name')) ?><br><input type="text" name="full_name" value="<?= h($managedUser->fullName()) ?>" required></label>
|
||||
<label><?= h(t('app.email', 'Email')) ?><br><input type="email" name="email" value="<?= h($managedUser->email()) ?>" required></label>
|
||||
<label><input type="checkbox" name="is_admin" value="1" <?= $managedUser->isAdmin() ? 'checked' : '' ?>> <?= h(t('app.admin', 'Admin')) ?></label>
|
||||
<label><input type="checkbox" name="is_enabled" value="1" <?= $managedUser->isEnabled() ? 'checked' : '' ?>> <?= h(t('app.enabled', 'Enabled')) ?></label>
|
||||
<span><?= h(fmt_time($managedUser->createdAt())) ?></span>
|
||||
<span><?= h(fmt_time($managedUser->lastLoginAt())) ?></span>
|
||||
<button type="submit"><?= h(t('app.update_user', 'Update user')) ?></button>
|
||||
</form>
|
||||
|
||||
<div class="user-row-actions">
|
||||
<form method="post" action="/users?lang=<?= h($language) ?>">
|
||||
<input type="hidden" name="action" value="set_password">
|
||||
<input type="hidden" name="email" value="<?= h($managedUser->email()) ?>">
|
||||
<label><?= h(t('app.new_password', 'New password')) ?><br><input type="password" name="password" autocomplete="new-password"></label>
|
||||
<button type="submit"><?= h(t('app.change_password', 'Change password')) ?></button>
|
||||
</form>
|
||||
|
||||
<?php if ($managedUser->id() !== $currentUser->id()): ?>
|
||||
<form method="post" action="/users?lang=<?= h($language) ?>"
|
||||
onsubmit="return confirm('<?= h(t('app.delete_user_confirm', 'Delete user')) ?> <?= h($managedUser->email()) ?>?');">
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="user_id" value="<?= h($managedUser->id()) ?>">
|
||||
<button type="submit"><?= h(t('app.delete_user', 'Delete user')) ?></button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="small"><?= h(t('app.cannot_delete_self', 'You cannot delete your own account.')) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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