initial import
This commit is contained in:
+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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user