Reorganize PHP internals and static assets

Move shared PHP code into private/, move JavaScript files into js/, and block direct access to private/. Remove unused API key and cache artifacts from the working tree.
This commit is contained in:
www-data
2026-05-26 11:32:36 +02:00
parent 97f23260ed
commit 2f2e8869d6
30 changed files with 48 additions and 48 deletions
+722
View File
@@ -0,0 +1,722 @@
<?php
/*
* auth.php
*
* Encapsulated authentication/user module for the Racket sandbox.
*
* Database:
* data/racket-sandbox.sqlite
*
* Public model:
* RacketSandboxAuth
* RacketSandboxUser
*
* Outside code should not inspect DB rows directly.
*/
class RacketSandboxAuthException extends Exception
{
}
class RacketSandboxUser
{
private $row;
public function __construct($row)
{
$this->row = is_array($row) ? $row : array();
}
public function id()
{
return (int)($this->row['id'] ?? 0);
}
public function email()
{
return (string)($this->row['email'] ?? '');
}
public function fullName()
{
return (string)($this->row['full_name'] ?? '');
}
public function displayName()
{
$name = $this->fullName();
$email = $this->email();
if ($name !== '' && $email !== '') {
return $name . ' <' . $email . '>';
}
if ($name !== '') {
return $name;
}
return $email;
}
public function isAdmin()
{
return (int)($this->row['is_admin'] ?? 0) === 1;
}
public function isEnabled()
{
return (int)($this->row['is_enabled'] ?? 0) === 1;
}
public function createdAt()
{
return isset($this->row['created_at']) ? (int)$this->row['created_at'] : null;
}
public function updatedAt()
{
return isset($this->row['updated_at']) ? (int)$this->row['updated_at'] : null;
}
public function lastLoginAt()
{
return isset($this->row['last_login_at']) && $this->row['last_login_at'] !== null
? (int)$this->row['last_login_at']
: null;
}
public function sessionExpiresAt()
{
return isset($this->row['session_expires_at']) && $this->row['session_expires_at'] !== null
? (int)$this->row['session_expires_at']
: null;
}
public function raw()
{
return $this->row;
}
}
class RacketSandboxAuth
{
private $db;
private $sessionCookieName;
private $sessionTtlSeconds;
public function __construct($dbFile, $options = array())
{
$dir = dirname($dbFile);
if (!is_dir($dir)) {
if (!mkdir($dir, 0700, true)) {
throw new RacketSandboxAuthException('Could not create database directory: ' . $dir);
}
}
$this->sessionCookieName = isset($options['cookie_name'])
? (string)$options['cookie_name']
: 'rkt_sandbox_session';
$this->sessionTtlSeconds = isset($options['session_ttl'])
? (int)$options['session_ttl']
: 12 * 3600;
$this->db = new PDO('sqlite:' . $dbFile);
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->db->exec('PRAGMA journal_mode = WAL');
$this->db->exec('PRAGMA busy_timeout = 5000');
$this->init();
}
private function init()
{
$this->db->exec(
'CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login_at INTEGER NULL
)'
);
$this->migrateUsersTable();
$this->db->exec(
'CREATE TABLE IF NOT EXISTS auth_sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
user_agent TEXT NULL,
remote_addr TEXT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)'
);
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
ON auth_sessions(user_id)'
);
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires
ON auth_sessions(expires_at)'
);
}
private function migrateUsersTable()
{
$cols = $this->tableColumns('users');
if (isset($cols['username']) && !isset($cols['email'])) {
$this->db->exec('ALTER TABLE users RENAME COLUMN username TO email');
$cols = $this->tableColumns('users');
}
if (!isset($cols['full_name'])) {
$this->db->exec("ALTER TABLE users ADD COLUMN full_name TEXT NOT NULL DEFAULT ''");
$this->db->exec("UPDATE users SET full_name = email WHERE full_name = ''");
}
}
private function tableColumns($table)
{
$stmt = $this->db->query('PRAGMA table_info(' . $table . ')');
$cols = array();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cols[$row['name']] = true;
}
return $cols;
}
public function createUser($email, $fullName, $password, $isAdmin = false, $enabled = true)
{
$email = $this->safeEmail($email);
$fullName = $this->safeFullName($fullName);
$this->assertPasswordUsable($password);
$now = time();
$hash = password_hash($password, PASSWORD_DEFAULT);
if ($hash === false) {
throw new RacketSandboxAuthException('Could not hash password.');
}
$stmt = $this->db->prepare(
'INSERT INTO users
(email, full_name, password_hash, is_admin, is_enabled, created_at, updated_at)
VALUES
(:email, :full_name, :password_hash, :is_admin, :is_enabled, :created_at, :updated_at)'
);
$stmt->execute(array(
':email' => $email,
':full_name' => $fullName,
':password_hash' => $hash,
':is_admin' => $isAdmin ? 1 : 0,
':is_enabled' => $enabled ? 1 : 0,
':created_at' => $now,
':updated_at' => $now,
));
return $this->getUserById((int)$this->db->lastInsertId());
}
public function login($email, $password)
{
$email = $this->safeEmail($email);
$stmt = $this->db->prepare(
'SELECT *
FROM users
WHERE email = :email'
);
$stmt->execute(array(':email' => $email));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new RacketSandboxAuthException('Invalid email address or password.');
}
if ((int)$row['is_enabled'] !== 1) {
throw new RacketSandboxAuthException('This account is disabled.');
}
if (!password_verify($password, $row['password_hash'])) {
throw new RacketSandboxAuthException('Invalid email address or password.');
}
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
$this->setPasswordById((int)$row['id'], $password, false);
}
$token = $this->createSession((int)$row['id']);
$this->setSessionCookie($token);
$stmt = $this->db->prepare(
'UPDATE users
SET last_login_at = :last_login_at
WHERE id = :id'
);
$stmt->execute(array(
':last_login_at' => time(),
':id' => (int)$row['id'],
));
return $this->getUserById((int)$row['id']);
}
public function logout()
{
$token = $this->cookieToken();
if ($token !== null) {
$this->deleteSession($token);
}
$this->clearSessionCookie();
}
public function currentUser()
{
$token = $this->cookieToken();
if ($token === null || !$this->validTokenSyntax($token)) {
return null;
}
$stmt = $this->db->prepare(
'SELECT
u.id,
u.email,
u.full_name,
u.is_admin,
u.is_enabled,
u.created_at,
u.updated_at,
u.last_login_at,
s.expires_at AS session_expires_at
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = :token'
);
$stmt->execute(array(':token' => $token));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
if ((int)$row['is_enabled'] !== 1) {
$this->deleteSession($token);
return null;
}
if (time() > (int)$row['session_expires_at']) {
$this->deleteSession($token);
return null;
}
$this->touchSession($token);
return new RacketSandboxUser($row);
}
public function requireLoginHtml()
{
$user = $this->currentUser();
if ($user !== null) {
return $user;
}
$this->loginRequiredHtml();
}
public function requireAdminHtml()
{
$user = $this->requireLoginHtml();
if ($user->isAdmin()) {
return $user;
}
$this->messageHtml(
'Not available',
'Admin rights are required for this page.'
);
}
public function listUsers()
{
$stmt = $this->db->query(
'SELECT
id, email, full_name, is_admin, is_enabled,
created_at, updated_at, last_login_at
FROM users
ORDER BY email COLLATE NOCASE'
);
$out = array();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = new RacketSandboxUser($row);
}
return $out;
}
public function getUser($id)
{
return $this->getUserById($id);
}
public function setAdmin($userId, $isAdmin)
{
$this->updateUserFlag($userId, 'is_admin', $isAdmin);
}
public function setEnabled($userId, $isEnabled)
{
$this->updateUserFlag($userId, 'is_enabled', $isEnabled);
if (!$isEnabled) {
$stmt = $this->db->prepare(
'DELETE FROM auth_sessions
WHERE user_id = :user_id'
);
$stmt->execute(array(':user_id' => (int)$userId));
}
}
public function updateUser($userId, $email, $fullName)
{
$userId = (int)$userId;
$email = $this->safeEmail($email);
$fullName = $this->safeFullName($fullName);
$stmt = $this->db->prepare(
'UPDATE users
SET email = :email,
full_name = :full_name,
updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute(array(
':email' => $email,
':full_name' => $fullName,
':updated_at' => time(),
':id' => $userId,
));
return $this->getUserById($userId);
}
public function setPassword($email, $newPassword)
{
$email = $this->safeEmail($email);
$stmt = $this->db->prepare(
'SELECT id
FROM users
WHERE email = :email'
);
$stmt->execute(array(':email' => $email));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new RacketSandboxAuthException('Unknown user: ' . $email);
}
$this->setPasswordById((int)$row['id'], $newPassword, true);
}
public function deleteUser($userId)
{
$userId = (int)$userId;
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare(
'DELETE FROM auth_sessions
WHERE user_id = :user_id'
);
$stmt->execute(array(':user_id' => $userId));
$stmt = $this->db->prepare(
'DELETE FROM users
WHERE id = :id'
);
$stmt->execute(array(':id' => $userId));
$this->db->commit();
} catch (Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
public function cleanupSessions()
{
$stmt = $this->db->prepare(
'DELETE FROM auth_sessions
WHERE expires_at < :now'
);
$stmt->execute(array(':now' => time()));
return $stmt->rowCount();
}
private function getUserById($id)
{
$stmt = $this->db->prepare(
'SELECT
id, email, full_name, is_admin, is_enabled,
created_at, updated_at, last_login_at
FROM users
WHERE id = :id'
);
$stmt->execute(array(':id' => (int)$id));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
return new RacketSandboxUser($row);
}
private function createSession($userId)
{
$token = bin2hex(random_bytes(32));
$now = time();
$stmt = $this->db->prepare(
'INSERT INTO auth_sessions
(token, user_id, created_at, expires_at, last_seen_at, user_agent, remote_addr)
VALUES
(:token, :user_id, :created_at, :expires_at, :last_seen_at, :user_agent, :remote_addr)'
);
$stmt->execute(array(
':token' => $token,
':user_id' => (int)$userId,
':created_at' => $now,
':expires_at' => $now + $this->sessionTtlSeconds,
':last_seen_at' => $now,
':user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
':remote_addr' => $_SERVER['REMOTE_ADDR'] ?? null,
));
return $token;
}
private function touchSession($token)
{
$now = time();
$stmt = $this->db->prepare(
'UPDATE auth_sessions
SET last_seen_at = :last_seen_at,
expires_at = :expires_at
WHERE token = :token'
);
$stmt->execute(array(
':last_seen_at' => $now,
':expires_at' => $now + $this->sessionTtlSeconds,
':token' => $token,
));
}
private function deleteSession($token)
{
$stmt = $this->db->prepare(
'DELETE FROM auth_sessions
WHERE token = :token'
);
$stmt->execute(array(':token' => $token));
}
private function setPasswordById($userId, $newPassword, $deleteSessions)
{
$this->assertPasswordUsable($newPassword);
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
if ($hash === false) {
throw new RacketSandboxAuthException('Could not hash password.');
}
$stmt = $this->db->prepare(
'UPDATE users
SET password_hash = :password_hash,
updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute(array(
':password_hash' => $hash,
':updated_at' => time(),
':id' => (int)$userId,
));
if ($deleteSessions) {
$stmt = $this->db->prepare(
'DELETE FROM auth_sessions
WHERE user_id = :user_id'
);
$stmt->execute(array(':user_id' => (int)$userId));
}
}
private function updateUserFlag($userId, $column, $value)
{
if ($column !== 'is_admin' && $column !== 'is_enabled') {
throw new RacketSandboxAuthException('Invalid flag column.');
}
$stmt = $this->db->prepare(
'UPDATE users
SET ' . $column . ' = :value,
updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute(array(
':value' => $value ? 1 : 0,
':updated_at' => time(),
':id' => (int)$userId,
));
}
private function cookieToken()
{
$token = $_COOKIE[$this->sessionCookieName] ?? null;
if (!is_string($token) || !$this->validTokenSyntax($token)) {
return null;
}
return $token;
}
private function setSessionCookie($token)
{
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie($this->sessionCookieName, $token, array(
'expires' => time() + $this->sessionTtlSeconds,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
));
}
private function clearSessionCookie()
{
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie($this->sessionCookieName, '', array(
'expires' => time() - 3600,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
));
}
private function validTokenSyntax($token)
{
return is_string($token) && preg_match('/^[a-f0-9]{64}$/', $token);
}
private function safeEmail($email)
{
$email = strtolower(trim((string)$email));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RacketSandboxAuthException('Invalid email address.');
}
if (strlen($email) > 160) {
throw new RacketSandboxAuthException('Email address is too long.');
}
return $email;
}
private function safeFullName($fullName)
{
$fullName = trim((string)$fullName);
if ($fullName === '') {
throw new RacketSandboxAuthException('Full name is required.');
}
if (strlen($fullName) > 160) {
throw new RacketSandboxAuthException('Full name is too long.');
}
return $fullName;
}
private function assertPasswordUsable($password)
{
if (!is_string($password) || strlen($password) < 10) {
throw new RacketSandboxAuthException('Password must be at least 10 characters.');
}
}
private function loginRequiredHtml()
{
$this->messageHtml(
'Login required',
'Please log in to continue.',
'<p><a href="/login.php">Login</a></p>'
);
}
private function messageHtml($title, $message, $extra = '')
{
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</title>
</head>
<body>
<h1>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>
<p>' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>
' . $extra . '
</body>
</html>';
exit;
}
}