Files
racket-chatgpt-bootstrap/index.php
T
2026-05-25 13:47:46 +02:00

471 lines
16 KiB
PHP

<?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>