- 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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user