- Implemented.

Rebuilt embed-example.html with inline, full-screen, and popup examples.
Fixed host-page/frame scrolling and contained reply navigation.
New logins now transactionally replace prior sessions; displaced clients 
return to login.
Added per-user session/IP locking with automatic SQLite migration.
Added modular Spam Control dashboard for locks, honeypot, and CAPTCHA 
settings.
Moved CAPTCHA controls out of Plans & Platform.
This commit is contained in:
Ty Clifford
2026-06-09 18:54:25 -04:00
parent 5ca55bf7c1
commit 36c488ca1e
12 changed files with 599 additions and 169 deletions
-72
View File
@@ -4,33 +4,10 @@ function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') {
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac');
if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = 'm4a_aac';
$captchaVersion = (string)($_POST['captcha_google_version'] ?? 'v2');
if (!in_array($captchaVersion, ['v2', 'v3'], true)) $captchaVersion = 'v2';
$captchaDifficulty = (string)($_POST['captcha_internal_difficulty'] ?? 'easy');
if (!in_array($captchaDifficulty, ['easy', 'medium'], true)) $captchaDifficulty = 'easy';
$captchaGoogleEnabled = !empty($_POST['captcha_google_enabled']);
$captchaGoogleSiteKey = trim((string)($_POST['captcha_google_site_key'] ?? ''));
$captchaGoogleSecretKey = trim((string)($_POST['captcha_google_secret_key'] ?? ''));
if ($captchaGoogleEnabled && ($captchaGoogleSiteKey === '' || $captchaGoogleSecretKey === '')) {
throw new RuntimeException('Google reCAPTCHA requires both a site key and secret key.');
}
setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'chat.replies_enabled' => !empty($_POST['replies_enabled']),
'chat.reply_max_depth' => max(1, min(20, (int)($_POST['reply_max_depth'] ?? 4))),
'security.captcha.registration_enabled' => !empty($_POST['captcha_registration_enabled']),
'security.captcha.login_enabled' => !empty($_POST['captcha_login_enabled']),
'security.captcha.honeypot.enabled' => !empty($_POST['captcha_honeypot_enabled']),
'security.captcha.internal.enabled' => !empty($_POST['captcha_internal_enabled']),
'security.captcha.internal.difficulty' => $captchaDifficulty,
'security.captcha.internal.ttl_seconds' => max(60, min(1800, (int)($_POST['captcha_internal_ttl'] ?? 300))),
'security.captcha.internal.max_attempts' => max(1, min(10, (int)($_POST['captcha_internal_attempts'] ?? 3))),
'security.captcha.google.enabled' => $captchaGoogleEnabled,
'security.captcha.google.version' => $captchaVersion,
'security.captcha.google.site_key' => $captchaGoogleSiteKey,
'security.captcha.google.secret_key' => $captchaGoogleSecretKey,
'security.captcha.google.v3_min_score' => max(0.0, min(1.0, (float)($_POST['captcha_google_v3_score'] ?? 0.5))),
'security.captcha.google.hostname' => trim((string)($_POST['captcha_google_hostname'] ?? '')),
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
@@ -182,55 +159,6 @@ function renderAdminPlatformTab(): void {
</div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// REGISTRATION / LOGIN CAPTCHA</span></div><div class="panel-body">
<p class="dim">CAPTCHA checks apply only to initial registration and password login. Email 2FA verification is not challenged.</p>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_registration_enabled"
<?= getConfigVal('security.captcha.registration_enabled', true) ? 'checked' : '' ?>><span>Protect registration</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_login_enabled"
<?= getConfigVal('security.captcha.login_enabled', true) ? 'checked' : '' ?>><span>Protect login</span></label>
</div>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_honeypot_enabled"
<?= getConfigVal('security.captcha.honeypot.enabled', true) ? 'checked' : '' ?>>
<span>Invisible honeypot fields that deny bot-like submissions</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_internal_enabled"
<?= getConfigVal('security.captcha.internal.enabled', true) ? 'checked' : '' ?>>
<span>Internal arithmetic challenge</span></label>
</div>
<div class="form-row3">
<div class="field"><label>Internal Difficulty</label><select name="captcha_internal_difficulty">
<?php $captchaDifficulty = getConfigVal('security.captcha.internal.difficulty', 'easy'); ?>
<option value="easy" <?= $captchaDifficulty === 'easy' ? 'selected' : '' ?>>Easy (1-9)</option>
<option value="medium" <?= $captchaDifficulty === 'medium' ? 'selected' : '' ?>>Medium (1-25)</option>
</select></div>
<div class="field"><label>Challenge TTL (seconds)</label><input type="number" min="60" max="1800"
name="captcha_internal_ttl" value="<?= (int)getConfigVal('security.captcha.internal.ttl_seconds', 300) ?>"></div>
<div class="field"><label>Maximum Attempts</label><input type="number" min="1" max="10"
name="captcha_internal_attempts" value="<?= (int)getConfigVal('security.captcha.internal.max_attempts', 3) ?>"></div>
</div>
<label class="cb-row"><input type="checkbox" name="captcha_google_enabled"
<?= getConfigVal('security.captcha.google.enabled', false) ? 'checked' : '' ?>>
<span>Also require Google reCAPTCHA</span></label>
<div class="form-row3">
<div class="field"><label>Google Version</label><select name="captcha_google_version">
<?php $captchaVersion = getConfigVal('security.captcha.google.version', 'v2'); ?>
<option value="v2" <?= $captchaVersion === 'v2' ? 'selected' : '' ?>>v2 checkbox</option>
<option value="v3" <?= $captchaVersion === 'v3' ? 'selected' : '' ?>>v3 score</option>
</select></div>
<div class="field"><label>Site Key</label><input name="captcha_google_site_key"
value="<?= esc(getConfigVal('security.captcha.google.site_key', '')) ?>"></div>
<div class="field"><label>Secret Key</label><input type="password" name="captcha_google_secret_key"
value="<?= esc(getConfigVal('security.captcha.google.secret_key', '')) ?>"></div>
</div>
<div class="form-row">
<div class="field"><label>v3 Minimum Score</label><input type="number" min="0" max="1" step="0.1"
name="captcha_google_v3_score" value="<?= esc(getConfigVal('security.captcha.google.v3_min_score', 0.5)) ?>"></div>
<div class="field"><label>Expected Hostname (optional)</label><input name="captcha_google_hostname"
value="<?= esc(getConfigVal('security.captcha.google.hostname', '')) ?>" placeholder="chat.example.com"></div>
</div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
<div class="form-row3">
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
+252
View File
@@ -0,0 +1,252 @@
<?php
function handleAdminSpamAction(string $action): ?string {
if ($action === 'save_spam_settings') {
$captchaVersion = (string)($_POST['captcha_google_version'] ?? 'v2');
if (!in_array($captchaVersion, ['v2', 'v3'], true)) $captchaVersion = 'v2';
$captchaDifficulty = (string)($_POST['captcha_internal_difficulty'] ?? 'easy');
if (!in_array($captchaDifficulty, ['easy', 'medium'], true)) $captchaDifficulty = 'easy';
$googleEnabled = !empty($_POST['captcha_google_enabled']);
$googleSiteKey = trim((string)($_POST['captcha_google_site_key'] ?? ''));
$googleSecretKey = trim((string)($_POST['captcha_google_secret_key'] ?? ''));
if ($googleEnabled && ($googleSiteKey === '' || $googleSecretKey === '')) {
throw new RuntimeException('Google reCAPTCHA requires both a site key and secret key.');
}
setConfigValues([
'chat.session_lock_ip' => !empty($_POST['session_ip_binding_enabled']),
'security.captcha.registration_enabled' => !empty($_POST['captcha_registration_enabled']),
'security.captcha.login_enabled' => !empty($_POST['captcha_login_enabled']),
'security.captcha.honeypot.enabled' => !empty($_POST['captcha_honeypot_enabled']),
'security.captcha.internal.enabled' => !empty($_POST['captcha_internal_enabled']),
'security.captcha.internal.difficulty' => $captchaDifficulty,
'security.captcha.internal.ttl_seconds' => max(60, min(1800, (int)($_POST['captcha_internal_ttl'] ?? 300))),
'security.captcha.internal.max_attempts' => max(1, min(10, (int)($_POST['captcha_internal_attempts'] ?? 3))),
'security.captcha.google.enabled' => $googleEnabled,
'security.captcha.google.version' => $captchaVersion,
'security.captcha.google.site_key' => $googleSiteKey,
'security.captcha.google.secret_key' => $googleSecretKey,
'security.captcha.google.v3_min_score' => max(0.0, min(1.0, (float)($_POST['captcha_google_v3_score'] ?? 0.5))),
'security.captcha.google.hostname' => trim((string)($_POST['captcha_google_hostname'] ?? '')),
]);
return 'Spam and authentication protection settings saved.';
}
if ($action === 'lock_user_session') {
$userId = (int)($_POST['user_id'] ?? 0);
$sessionId = trim((string)($_POST['session_id'] ?? ''));
$db = getDB();
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$stmt = $db->prepare("SELECT id,user_id,ip FROM sessions WHERE id=? AND user_id=? AND last_active>?");
$stmt->execute([$sessionId, $userId, $cutoff]);
$session = $stmt->fetch();
if (!$session) throw new RuntimeException('The selected session is no longer available.');
$db->beginTransaction();
try {
$db->prepare("DELETE FROM sessions WHERE user_id=? AND id<>?")->execute([$userId, $sessionId]);
$db->prepare("UPDATE users SET session_locked=1,session_lock_ip=?,
session_lock_session_id=?,session_locked_at=? WHERE id=?")
->execute([$session['ip'], $sessionId, time(), $userId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
return 'User locked to the selected session and IP.';
}
if ($action === 'unlock_user_session') {
$userId = (int)($_POST['user_id'] ?? 0);
$stmt = getDB()->prepare("UPDATE users SET session_locked=0,session_lock_ip=NULL,
session_lock_session_id=NULL,session_locked_at=NULL WHERE id=?");
$stmt->execute([$userId]);
if ($stmt->rowCount() < 1) throw new RuntimeException('User was not found or was already unlocked.');
return 'User session lock removed. Their next login will replace any existing session.';
}
return null;
}
function renderAdminSpamTab(): void {
$db = getDB();
$query = trim((string)($_GET['q'] ?? ''));
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$where = ' WHERE s.last_active>?';
$params = [$cutoff];
if ($query !== '') {
$where .= ' AND (u.username LIKE ? OR s.ip LIKE ?)';
$like = '%' . $query . '%';
$params[] = $like;
$params[] = $like;
}
$sessionStmt = $db->prepare("SELECT s.*,u.username,u.color,u.session_locked,
u.session_lock_ip,u.session_lock_session_id,u.session_locked_at
FROM sessions s JOIN users u ON u.id=s.user_id $where
ORDER BY s.last_active DESC LIMIT 500");
$sessionStmt->execute($params);
$sessions = $sessionStmt->fetchAll();
$lockedStmt = $db->prepare("SELECT u.id,u.username,u.color,u.session_lock_ip,
u.session_lock_session_id,u.session_locked_at,s.last_active
FROM users u LEFT JOIN sessions s ON s.id=u.session_lock_session_id
WHERE u.session_locked=1" . ($query !== '' ? " AND (u.username LIKE ? OR u.session_lock_ip LIKE ?)" : '') . "
ORDER BY u.session_locked_at DESC LIMIT 500");
$lockedStmt->execute($query !== '' ? array_slice($params, 1) : []);
$lockedUsers = $lockedStmt->fetchAll();
?>
<div class="page-head">
<h1>SPAM CONTROL</h1>
<p>// session pinning, login replacement, honeypot, and CAPTCHA controls</p>
</div>
<div class="stat-grid">
<div class="stat-card orange"><div class="stat-num"><?= count($lockedUsers) ?></div><div class="stat-label">Locked Accounts</div></div>
<div class="stat-card blue"><div class="stat-num"><?= count($sessions) ?></div><div class="stat-label">Visible Sessions</div></div>
</div>
<form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="save_spam_settings">
<div class="panel">
<div class="panel-head"><span class="panel-title">// SESSION SECURITY</span></div>
<div class="panel-body">
<p class="dim" style="margin-bottom:12px">By default, a successful login silently terminates the user's older session. Locking below pins an account to one session token and IP until an administrator unlocks it.</p>
<label class="cb-row"><input type="checkbox" name="session_ip_binding_enabled"
<?= getConfigVal('chat.session_lock_ip', true) ? 'checked' : '' ?>>
<span>Require every active session to continue using the IP address where it logged in</span></label>
</div>
</div>
<div class="panel">
<div class="panel-head"><span class="panel-title">// REGISTRATION / LOGIN CAPTCHA</span></div>
<div class="panel-body">
<p class="dim" style="margin-bottom:12px">These checks apply only to registration and initial password login. Email 2FA, chat messages, and dashboard actions are not challenged.</p>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_registration_enabled"
<?= getConfigVal('security.captcha.registration_enabled', true) ? 'checked' : '' ?>><span>Protect registration</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_login_enabled"
<?= getConfigVal('security.captcha.login_enabled', true) ? 'checked' : '' ?>><span>Protect login</span></label>
</div>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_honeypot_enabled"
<?= getConfigVal('security.captcha.honeypot.enabled', true) ? 'checked' : '' ?>>
<span>Invisible honeypot fields that deny bot-like submissions</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_internal_enabled"
<?= getConfigVal('security.captcha.internal.enabled', true) ? 'checked' : '' ?>>
<span>Internal arithmetic challenge</span></label>
</div>
<div class="form-row3">
<div class="field"><label>Internal Difficulty</label><select name="captcha_internal_difficulty">
<?php $difficulty = getConfigVal('security.captcha.internal.difficulty', 'easy'); ?>
<option value="easy" <?= $difficulty === 'easy' ? 'selected' : '' ?>>Easy (1-9)</option>
<option value="medium" <?= $difficulty === 'medium' ? 'selected' : '' ?>>Medium (1-25)</option>
</select></div>
<div class="field"><label>Challenge TTL (seconds)</label><input type="number" min="60" max="1800"
name="captcha_internal_ttl" value="<?= (int)getConfigVal('security.captcha.internal.ttl_seconds', 300) ?>"></div>
<div class="field"><label>Maximum Attempts</label><input type="number" min="1" max="10"
name="captcha_internal_attempts" value="<?= (int)getConfigVal('security.captcha.internal.max_attempts', 3) ?>"></div>
</div>
<label class="cb-row"><input type="checkbox" name="captcha_google_enabled"
<?= getConfigVal('security.captcha.google.enabled', false) ? 'checked' : '' ?>>
<span>Also require Google reCAPTCHA</span></label>
<div class="form-row3">
<div class="field"><label>Google Version</label><select name="captcha_google_version">
<?php $version = getConfigVal('security.captcha.google.version', 'v2'); ?>
<option value="v2" <?= $version === 'v2' ? 'selected' : '' ?>>v2 checkbox</option>
<option value="v3" <?= $version === 'v3' ? 'selected' : '' ?>>v3 score</option>
</select></div>
<div class="field"><label>Site Key</label><input name="captcha_google_site_key"
value="<?= esc(getConfigVal('security.captcha.google.site_key', '')) ?>"></div>
<div class="field"><label>Secret Key</label><input type="password" name="captcha_google_secret_key"
value="<?= esc(getConfigVal('security.captcha.google.secret_key', '')) ?>"></div>
</div>
<div class="form-row">
<div class="field"><label>v3 Minimum Score</label><input type="number" min="0" max="1" step="0.1"
name="captcha_google_v3_score" value="<?= esc(getConfigVal('security.captcha.google.v3_min_score', 0.5)) ?>"></div>
<div class="field"><label>Expected Hostname (optional)</label><input name="captcha_google_hostname"
value="<?= esc(getConfigVal('security.captcha.google.hostname', '')) ?>" placeholder="chat.example.com"></div>
</div>
<button class="btn btn-g" type="submit">SAVE SPAM CONTROLS</button>
</div>
</div>
</form>
<form class="search-bar" method="get">
<input type="hidden" name="tab" value="spam">
<input type="text" name="q" value="<?= esc($query) ?>" placeholder="Search username or IP">
<button class="btn btn-b" type="submit">SEARCH</button>
<?php if ($query !== ''): ?><a class="btn btn-t1" href="?tab=spam">CLEAR</a><?php endif; ?>
</form>
<div class="panel">
<div class="panel-head"><span class="panel-title">// ACTIVE SESSION LOCKS</span></div>
<div class="panel-body np">
<?php if (!$sessions): ?>
<div class="empty">NO MATCHING ACTIVE SESSIONS</div>
<?php else: ?>
<div class="tbl-wrap"><table>
<thead><tr><th>User</th><th>IP</th><th>Session</th><th>Last Active</th><th>Lock State</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($sessions as $session):
$isPinned = !empty($session['session_locked'])
&& hash_equals((string)$session['session_lock_session_id'], (string)$session['id']);
?>
<tr>
<td style="color:<?= esc($session['color']) ?>;font-family:var(--mono)"><?= esc($session['username']) ?></td>
<td class="mono"><?= esc($session['ip']) ?></td>
<td class="mono dim"><?= esc(substr((string)$session['id'], 0, 16)) ?>...</td>
<td class="dim"><?= ago((int)$session['last_active']) ?></td>
<td><span class="badge <?= $isPinned ? 'badge-o' : 'badge-g' ?>"><?= $isPinned ? 'LOCKED HERE' : 'REPLACEABLE' ?></span></td>
<td class="actions">
<?php if (!empty($session['session_locked'])): ?>
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="unlock_user_session">
<input type="hidden" name="user_id" value="<?= (int)$session['user_id'] ?>">
<button class="btn btn-sm btn-g" type="submit">UNLOCK</button></form>
<?php else: ?>
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="lock_user_session">
<input type="hidden" name="user_id" value="<?= (int)$session['user_id'] ?>">
<input type="hidden" name="session_id" value="<?= esc($session['id']) ?>">
<button class="btn btn-sm btn-o" type="submit"
onclick="return confirm('Lock this account to the selected session and IP?')">LOCK</button></form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<?php endif; ?>
</div>
</div>
<?php if ($lockedUsers): ?>
<div class="panel">
<div class="panel-head"><span class="panel-title">// ALL LOCKED ACCOUNTS</span></div>
<div class="panel-body np"><div class="tbl-wrap"><table>
<thead><tr><th>User</th><th>Pinned IP</th><th>Pinned Session</th><th>Locked</th><th>Session Status</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($lockedUsers as $user):
$hasActiveSession = !empty($user['last_active']) && (int)$user['last_active'] > $cutoff;
?>
<tr>
<td style="color:<?= esc($user['color']) ?>;font-family:var(--mono)"><?= esc($user['username']) ?></td>
<td class="mono"><?= esc($user['session_lock_ip'] ?: '-') ?></td>
<td class="mono dim"><?= $user['session_lock_session_id'] ? esc(substr((string)$user['session_lock_session_id'], 0, 16)) . '...' : '-' ?></td>
<td class="dim"><?= $user['session_locked_at'] ? fmtTime((int)$user['session_locked_at']) : '-' ?></td>
<td><span class="badge <?= $hasActiveSession ? 'badge-g' : 'badge-r' ?>"><?= $hasActiveSession ? 'ACTIVE' : 'MISSING / EXPIRED' ?></span></td>
<td class="actions"><form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="unlock_user_session">
<input type="hidden" name="user_id" value="<?= (int)$user['id'] ?>">
<button class="btn btn-sm btn-g" type="submit">UNLOCK</button>
</form></td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div></div>
</div>
<?php endif;
}