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
+355
View File
@@ -0,0 +1,355 @@
<?php
/*
* nexttoken.php
*
* Eenvoudige SQLite-backed next-token store.
*
* Model:
* - Iedere gegenereerde HTML-pagina maakt één next-token.
* - Alle links op die pagina gebruiken datzelfde token.
* - Vervolgroutes accepteren een request alleen als next bestaat
* en nog niet verlopen is.
* - Het token heeft verder geen betekenis.
*/
class NextTokenException extends Exception
{
}
class NextTokenStore
{
private $db;
private $ttlSeconds;
public function __construct($dbFile, $ttlSeconds = 21600)
{
$this->ttlSeconds = (int)$ttlSeconds;
$dir = dirname($dbFile);
if (!is_dir($dir)) {
if (!mkdir($dir, 0700, true)) {
throw new NextTokenException('Could not create token db dir: ' . $dir);
}
}
$this->db = new PDO('sqlite:' . $dbFile);
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->db->exec('PRAGMA journal_mode = WAL');
$this->db->exec('PRAGMA busy_timeout = 5000');
$this->init();
}
private function init()
{
$this->db->exec(
'CREATE TABLE IF NOT EXISTS next_tokens (
token TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)'
);
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_next_tokens_expires
ON next_tokens(expires_at)'
);
$this->db->exec(
'CREATE TABLE IF NOT EXISTS next_token_words (
word TEXT NOT NULL,
kind TEXT NOT NULL,
language TEXT NOT NULL,
PRIMARY KEY(word, kind, language)
)'
);
$this->migrateTokenWordsTable();
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_next_token_words_kind
ON next_token_words(kind, language)'
);
$this->seedWords();
}
private function migrateTokenWordsTable()
{
$stmt = $this->db->query('PRAGMA table_info(next_token_words)');
$pkCols = array();
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ((int)$row['pk'] > 0) {
$pkCols[(int)$row['pk']] = $row['name'];
}
}
ksort($pkCols);
if (array_values($pkCols) === array('word', 'kind', 'language')) {
return;
}
$this->db->beginTransaction();
try {
$this->db->exec(
'CREATE TABLE next_token_words_new (
word TEXT NOT NULL,
kind TEXT NOT NULL,
language TEXT NOT NULL,
PRIMARY KEY(word, kind, language)
)'
);
$this->db->exec(
'INSERT OR IGNORE INTO next_token_words_new (word, kind, language)
SELECT word, kind, language
FROM next_token_words'
);
$this->db->exec('DROP TABLE next_token_words');
$this->db->exec('ALTER TABLE next_token_words_new RENAME TO next_token_words');
$this->db->commit();
} catch (Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
public function create($ttlSeconds = null)
{
$ttl = $ttlSeconds === null ? $this->ttlSeconds : (int)$ttlSeconds;
if ($ttl <= 0) {
throw new NextTokenException('Invalid ttl.');
}
$this->cleanup();
$now = time();
$stmt = $this->db->prepare(
'INSERT INTO next_tokens (token, created_at, expires_at)
VALUES (:token, :created_at, :expires_at)'
);
for ($i = 0; $i < 25; $i++) {
$token = $this->newToken();
try {
$stmt->execute(array(
':token' => $token,
':created_at' => $now,
':expires_at' => $now + $ttl,
));
return $token;
} catch (PDOException $e) {
if ($e->getCode() !== '23000') {
throw $e;
}
}
}
throw new NextTokenException('Could not create unique next token.');
}
public function isValid($token)
{
if (!is_string($token) || !preg_match('/^[a-z0-9-]{5,160}$/', $token)) {
return false;
}
$stmt = $this->db->prepare(
'SELECT expires_at
FROM next_tokens
WHERE token = :token'
);
$stmt->execute(array(':token' => $token));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return false;
}
return time() <= (int)$row['expires_at'];
}
public function requireValid($token)
{
if (!$this->isValid($token)) {
throw new NextTokenException('Invalid or expired next token.');
}
return true;
}
public function cleanup()
{
$stmt = $this->db->prepare(
'DELETE FROM next_tokens
WHERE expires_at < :now'
);
$stmt->execute(array(':now' => time()));
return $stmt->rowCount();
}
public function currentTokens()
{
$stmt = $this->db->prepare(
'SELECT token, created_at, expires_at
FROM next_tokens
WHERE expires_at >= :now
ORDER BY expires_at ASC, created_at ASC'
);
$stmt->execute(array(':now' => time()));
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function tokens()
{
$stmt = $this->db->query(
'SELECT token, created_at, expires_at
FROM next_tokens
ORDER BY expires_at ASC, created_at ASC'
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function expired_html_response()
{
html_response('<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Expired link</title>
</head>
<body>
<h1>Expired or unauthorized link</h1>
<p>
This link is no longer valid. Please ask the human user for a new
bootstrap link and restart the process from that link.
</p>
<p>
For the AI agent: do not try to invent a new <code>next</code> value.
Only follow links that appear on authorized HTML pages.
</p>
</body>
</html>', 200);
}
public function expired_text_response()
{
text_response(
"Expired or unauthorized link.\n" .
"Please ask the human user for a new bootstrap link.\n" .
"Do not invent a new next value; only follow links from authorized HTML pages.\n",
200
);
}
public function expired_response($kind = 'html')
{
if ($kind === 'html') {
$this->expired_html_response();
} else {
$this->expired_text_response();
}
}
public function check_valid_next($kind = 'html')
{
try {
$this->requireValid($_GET['next'] ?? '');
} catch (Throwable $e) {
$this->expired_response($kind);
}
}
private function newToken()
{
$adjective = $this->randomWord('adjective');
$noun = $this->randomWord('noun');
return $adjective . '-' . $noun;
}
private function randomWord($kind)
{
$stmt = $this->db->prepare(
'SELECT word
FROM next_token_words
WHERE kind = :kind
ORDER BY RANDOM()
LIMIT 1'
);
$stmt->execute(array(':kind' => $kind));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new NextTokenException('No token words available for: ' . $kind);
}
return (string)$row['word'];
}
private function seedWords()
{
$wordsFile = __DIR__ . '/config/next-token-words.php';
if (!is_file($wordsFile)) {
throw new NextTokenException('Next token word seed file not found.');
}
$words = require $wordsFile;
if (!is_array($words)) {
throw new NextTokenException('Invalid next token word seed file.');
}
$stmt = $this->db->prepare(
'INSERT OR IGNORE INTO next_token_words
(word, kind, language)
VALUES (:word, :kind, :language)'
);
foreach ($words as $language => $byKind) {
if (!is_array($byKind)) {
continue;
}
foreach ($byKind as $kind => $list) {
if ($kind !== 'adjective' && $kind !== 'noun') {
continue;
}
if (!is_array($list)) {
continue;
}
foreach ($list as $word) {
$word = trim((string)$word);
if (!preg_match('/^[a-z0-9-]+$/', $word)) {
continue;
}
$stmt->execute(array(
':word' => $word,
':kind' => $kind,
':language' => $language,
));
}
}
}
}
}