- 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
+15 -1
View File
@@ -6,7 +6,21 @@ CyberChat `2.1.0` is the current baseline release. The earlier `2.0.0` commit is
## [Unreleased] ## [Unreleased]
No changes have been documented after the `2.1.0` baseline. ### Added
- Added full-screen overlay and popup window examples to `embed-example.html`.
- Added a Spam Control dashboard for session pinning, IP binding, honeypot, internal CAPTCHA, and Google reCAPTCHA settings.
- Added per-user account locks tied to an administrator-selected session token and IP.
### Changed
- Successful logins now silently replace all older sessions for the same account.
- Moved registration/login CAPTCHA controls out of Plans & Platform and into Spam Control.
- Scoped full-page layout rules to `index.html` so embedded chat CSS does not alter the host page.
### Fixed
- Kept message and reply navigation scrolling inside the chat frame instead of moving the surrounding page.
## [2.1.0] - 2026-06-09 - Current Baseline Release ## [2.1.0] - 2026-06-09 - Current Baseline Release
+11 -7
View File
@@ -49,7 +49,9 @@ CyberChat 2.2.0 is the current baseline release. It is a framework-free PHP, SQL
- Registration with username and password; email is not required - Registration with username and password; email is not required
- Case-insensitive unique usernames using letters, numbers, underscores, and hyphens - Case-insensitive unique usernames using letters, numbers, underscores, and hyphens
- Bcrypt password hashing with a configurable cost - Bcrypt password hashing with a configurable cost
- Optional IP conflict restriction when an account already has an active session - Successful logins automatically terminate the same user's older session
- Administrator session locks that pin an account to one session token and IP until unlocked
- Optional IP binding for every active session
- Configurable session lifetime with HTTP-only, strict SameSite cookies and the secure flag under HTTPS - Configurable session lifetime with HTTP-only, strict SameSite cookies and the secure flag under HTTPS
- Email verification by six-digit code or verification link - Email verification by six-digit code or verification link
- Verified email required only before premium checkout - Verified email required only before premium checkout
@@ -322,7 +324,7 @@ Reply IDs, reply depth, parent previews, and voice bitrate metadata are retained
CAPTCHA checks are scoped to registration and initial password login. Email 2FA code verification, chat messages, account updates, and dashboard actions do not invoke CAPTCHA. CAPTCHA checks are scoped to registration and initial password login. Email 2FA code verification, chat messages, account updates, and dashboard actions do not invoke CAPTCHA.
Dashboard controls under **Plans & Platform > Registration / Login CAPTCHA** configure: Dashboard controls under **Spam Control > Registration / Login CAPTCHA** configure:
- Registration and login protection independently - Registration and login protection independently
- Invisible honeypot protection - Invisible honeypot protection
@@ -363,7 +365,8 @@ This design avoids overlapping poll requests, duplicate voice players, and tempo
| Archives | Browse days, inspect file sizes, filter/edit/delete messages, delete days | | Archives | Browse days, inspect file sizes, filter/edit/delete messages, delete days |
| Users | Create users, edit credentials/colors, assign tiers, kick, delete | | Users | Create users, edit credentials/colors, assign tiers, kick, delete |
| Sessions | Inspect active/expired sessions and terminate one, expired, or all | | Sessions | Inspect active/expired sessions and terminate one, expired, or all |
| Plans & Platform | Plans, pricing, bitrate, replies, CAPTCHA, Stripe, Mailgun, FFmpeg, MySQL, statistics | | Spam Control | Search sessions, pin or unlock accounts, configure IP binding, honeypot, internal CAPTCHA, and Google reCAPTCHA |
| Plans & Platform | Plans, pricing, bitrate, replies, Stripe, Mailgun, FFmpeg, MySQL, statistics |
| Config | Edit and validate `config.json` directly | | Config | Edit and validate `config.json` directly |
## Configuration Families ## Configuration Families
@@ -424,18 +427,18 @@ The diagnostic endpoint exposes deployment details and should be restricted or r
## Embedding ## Embedding
```html ```html
<link rel="stylesheet" href="/chat/assets/css/cyberchat.css?v=20260609.4"> <link rel="stylesheet" href="/chat/assets/css/cyberchat.css?v=20260609.5">
<div id="my-chat" style="width:100%;height:600px"></div> <div id="my-chat" style="width:100%;height:600px;overflow:hidden"></div>
<script> <script>
window.CYBERCHAT_BASE = '/chat'; window.CYBERCHAT_BASE = '/chat';
</script> </script>
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.8"></script> <script src="/chat/assets/js/cyberchat-app.js?v=20260609.10"></script>
<script> <script>
CyberChat.init('#my-chat'); CyberChat.init('#my-chat');
</script> </script>
``` ```
See `embed-example.html` for a complete example. The chat scrolls inside its own container and does not change the host page's body overflow. See `embed-example.html` for inline, full-screen overlay, and popout window examples.
## Main Files ## Main Files
@@ -458,6 +461,7 @@ api/stripe-webhook.php Stripe event synchronization
assets/css/cyberchat.css Cyberpunk and light interface themes assets/css/cyberchat.css Cyberpunk and light interface themes
assets/js/cyberchat-app.js Browser chat, audio player, polling, and account UI assets/js/cyberchat-app.js Browser chat, audio player, polling, and account UI
lib/admin_platform.php Plans and platform dashboard module lib/admin_platform.php Plans and platform dashboard module
lib/admin_spam.php Session locking and anti-spam dashboard module
lib/mailer.php Reusable Mailgun sender lib/mailer.php Reusable Mailgun sender
lib/mysql_mirror.php Optional SQLite-to-MySQL mirror lib/mysql_mirror.php Optional SQLite-to-MySQL mirror
lib/stripe.php Stripe REST client and subscription sync lib/stripe.php Stripe REST client and subscription sync
+17
View File
@@ -13,6 +13,7 @@ define('CONFIG_FILE', ROOT_DIR . '/config.json');
define('ADMIN_VERSION', '2.2.0'); define('ADMIN_VERSION', '2.2.0');
require_once ROOT_DIR . '/bootstrap.php'; require_once ROOT_DIR . '/bootstrap.php';
require_once ROOT_DIR . '/lib/admin_platform.php'; require_once ROOT_DIR . '/lib/admin_platform.php';
require_once ROOT_DIR . '/lib/admin_spam.php';
// ─── CHANGE THIS PASSWORD ──────────────────────────────────────────────────── // ─── CHANGE THIS PASSWORD ────────────────────────────────────────────────────
define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123')); define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123'));
@@ -200,6 +201,16 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('tab=platform'); redirect('tab=platform');
} }
if (in_array($act, ['save_spam_settings','lock_user_session','unlock_user_session'], true)) {
try {
$message = handleAdminSpamAction($act);
flash($message ?: 'Spam control action completed.');
} catch (Throwable $e) {
flash($e->getMessage(), 'err');
}
redirect('tab=spam');
}
// ── Messages ────────────────────────────────────────────────────────────── // ── Messages ──────────────────────────────────────────────────────────────
if ($act === 'archive_voice_clip') { if ($act === 'archive_voice_clip') {
$id = (int)($_POST['msg_id'] ?? 0); $id = (int)($_POST['msg_id'] ?? 0);
@@ -888,6 +899,9 @@ td.actions{white-space:nowrap;width:1px}
<a class="sb-link <?= $tab==='sessions'?'active':'' ?>" href="?tab=sessions"> <a class="sb-link <?= $tab==='sessions'?'active':'' ?>" href="?tab=sessions">
<span class="ico">⚡</span><span>Sessions</span> <span class="ico">⚡</span><span>Sessions</span>
</a> </a>
<a class="sb-link <?= $tab==='spam'?'active':'' ?>" href="?tab=spam">
<span class="ico">!</span><span>Spam Control</span>
</a>
<a class="sb-link <?= $tab==='platform'?'active':'' ?>" href="?tab=platform"> <a class="sb-link <?= $tab==='platform'?'active':'' ?>" href="?tab=platform">
<span class="ico">◆</span><span>Plans & Platform</span> <span class="ico">◆</span><span>Plans & Platform</span>
@@ -1695,6 +1709,9 @@ td.actions{white-space:nowrap;width:1px}
</div> </div>
<?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?> <?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?>
<?php elseif ($tab === 'spam'): ?>
<?php renderAdminSpamTab(); ?>
<?php elseif ($tab === 'platform'): ?> <?php elseif ($tab === 'platform'): ?>
<?php renderAdminPlatformTab(); ?> <?php renderAdminPlatformTab(); ?>
+2 -9
View File
@@ -64,15 +64,7 @@ function loginUser(): never {
jsonResponse(['error' => 'Invalid username or password'], 401); jsonResponse(['error' => 'Invalid username or password'], 401);
} }
if (getConfigVal('chat.session_lock_ip', true) || getConfigVal('chat.session_lock_cookie', true)) { assertUserSessionLoginAllowed($db, (int)$user['id']);
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$existingStmt = $db->prepare("SELECT id,ip FROM sessions WHERE user_id=? AND last_active>?");
$existingStmt->execute([$user['id'], $cutoff]);
$existing = $existingStmt->fetch();
if ($existing && getConfigVal('chat.session_lock_ip', true) && $existing['ip'] !== clientIP()) {
jsonResponse(['error' => 'Already logged in from another location'], 403);
}
}
if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) { if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) {
if (empty($user['email_verified_at']) || empty($user['email'])) { if (empty($user['email_verified_at']) || empty($user['email'])) {
@@ -109,6 +101,7 @@ function verifyTwoFactor(): never {
if ($row) $db->prepare("UPDATE login_challenges SET attempts=attempts+1 WHERE id=?")->execute([$challenge]); if ($row) $db->prepare("UPDATE login_challenges SET attempts=attempts+1 WHERE id=?")->execute([$challenge]);
jsonResponse(['error' => 'Invalid or expired login code'], 401); jsonResponse(['error' => 'Invalid or expired login code'], 401);
} }
assertUserSessionLoginAllowed($db, (int)$row['user_id']);
$db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]); $db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]);
createUserSession($db, (int)$row['user_id']); createUserSession($db, (int)$row['user_id']);
trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']); trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']);
+11 -4
View File
@@ -67,7 +67,7 @@ input, button { font-family: inherit; }
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 360px; min-height: 0;
background: var(--bg0); background: var(--bg0);
color: var(--t0); color: var(--t0);
font-family: var(--ui); font-family: var(--ui);
@@ -76,6 +76,8 @@ input, button { font-family: inherit; }
border: 1px solid var(--bd); border: 1px solid var(--bd);
border-radius: var(--r2); border-radius: var(--r2);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain;
contain: layout paint;
position: relative; position: relative;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -222,6 +224,7 @@ input, button { font-family: inherit; }
/* ─── Body area ──────────────────────────────────────────────────────────── */ /* ─── Body area ──────────────────────────────────────────────────────────── */
.cc-body { .cc-body {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@@ -629,7 +632,10 @@ input, button { font-family: inherit; }
/* Messages */ /* Messages */
.cc-messages { .cc-messages {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 6px 0 4px; padding: 6px 0 4px;
@@ -1239,14 +1245,15 @@ input, button { font-family: inherit; }
.cc-auth-wrap { align-items: flex-start; } .cc-auth-wrap { align-items: flex-start; }
} }
/* ─── Full-page (index.html) embed ───────────────────────────────────────── */ /* ─── Full-page (index.html) shell ───────────────────────────────────────── */
html, body { html.cc-fullpage-document,
body.cc-fullpage {
width: 100%; height: 100%; width: 100%; height: 100%;
margin: 0; padding: 0; margin: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
#app { body.cc-fullpage #app {
width: 100%; height: 100%; width: 100%; height: 100%;
} }
+11 -1
View File
@@ -55,6 +55,7 @@
}); });
const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' })); const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' }));
if (!response.ok && !data.error) data.error = 'Request failed'; if (!response.ok && !data.error) data.error = 'Request failed';
if (data && typeof data === 'object') data.http_status = response.status;
return data; return data;
} }
function feature(key, fallback = false) { function feature(key, fallback = false) {
@@ -631,6 +632,11 @@
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') { if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') {
return { ok: false, hasMore: false }; return { ok: false, hasMore: false };
} }
if (result?.http_status === 401 || result?.http_status === 423) {
await logout(false);
toast('Your session ended or was replaced. Sign in again.', 'info');
return { ok: false, hasMore: false };
}
if (!result || result.error || !Array.isArray(result.messages)) { if (!result || result.error || !Array.isArray(result.messages)) {
throw new Error(result?.error || 'Invalid poll response'); throw new Error(result?.error || 'Invalid poll response');
} }
@@ -798,7 +804,11 @@
const parentId = Number(event.currentTarget.dataset.parentId); const parentId = Number(event.currentTarget.dataset.parentId);
const parent = $(`.cc-msg[data-id="${parentId}"]`); const parent = $(`.cc-msg[data-id="${parentId}"]`);
if (!parent) return toast('Original message is outside the current view', 'info'); if (!parent) return toast('Original message is outside the current view', 'info');
parent.scrollIntoView({ behavior: 'smooth', block: 'center' }); const list = $('#messages');
if (list) {
const top = parent.offsetTop - ((list.clientHeight - parent.offsetHeight) / 2);
list.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
parent.classList.add('cc-msg-focus'); parent.classList.add('cc-msg-focus');
setTimeout(() => parent.classList.remove('cc-msg-focus'), 1200); setTimeout(() => parent.classList.remove('cc-msg-focus'), 1200);
}); });
+56 -3
View File
@@ -238,6 +238,10 @@ function initDB(PDO $db): void {
subscription_period_end INTEGER, subscription_period_end INTEGER,
cancel_at_period_end INTEGER NOT NULL DEFAULT 0, cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
two_factor_enabled INTEGER NOT NULL DEFAULT 0, two_factor_enabled INTEGER NOT NULL DEFAULT 0,
session_locked INTEGER NOT NULL DEFAULT 0,
session_lock_ip TEXT,
session_lock_session_id TEXT,
session_locked_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
last_seen INTEGER last_seen INTEGER
)"); )");
@@ -252,6 +256,10 @@ function initDB(PDO $db): void {
'subscription_period_end' => 'INTEGER', 'subscription_period_end' => 'INTEGER',
'cancel_at_period_end' => 'INTEGER NOT NULL DEFAULT 0', 'cancel_at_period_end' => 'INTEGER NOT NULL DEFAULT 0',
'two_factor_enabled' => 'INTEGER NOT NULL DEFAULT 0', 'two_factor_enabled' => 'INTEGER NOT NULL DEFAULT 0',
'session_locked' => 'INTEGER NOT NULL DEFAULT 0',
'session_lock_ip' => 'TEXT',
'session_lock_session_id' => 'TEXT',
'session_locked_at' => 'INTEGER',
]); ]);
$db->exec("CREATE TABLE IF NOT EXISTS sessions ( $db->exec("CREATE TABLE IF NOT EXISTS sessions (
@@ -685,6 +693,16 @@ function authUser(bool $required = true): ?array {
if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401); if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401);
return null; return null;
} }
if (!empty($user['session_locked'])) {
$lockedSession = (string)($user['session_lock_session_id'] ?? '');
$lockedIp = (string)($user['session_lock_ip'] ?? '');
if ($lockedSession === '' || $lockedIp === ''
|| !hash_equals($lockedSession, (string)$user['session_id'])
|| !hash_equals($lockedIp, clientIP())) {
if ($required) jsonResponse(['error' => 'This account is locked to another session'], 423);
return null;
}
}
getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]); getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]);
return $user; return $user;
} }
@@ -695,12 +713,47 @@ function authRequired(): array {
return $user; return $user;
} }
function assertUserSessionLoginAllowed(PDO $db, int $userId): void {
$stmt = $db->prepare("SELECT session_locked,session_lock_ip,session_lock_session_id FROM users WHERE id=?");
$stmt->execute([$userId]);
$lock = $stmt->fetch();
if (!$lock || empty($lock['session_locked'])) return;
$currentSession = (string)($_COOKIE['cyberchat_sid'] ?? '');
$lockedSession = (string)($lock['session_lock_session_id'] ?? '');
$lockedIp = (string)($lock['session_lock_ip'] ?? '');
if ($currentSession === '' || $lockedSession === '' || $lockedIp === ''
|| !hash_equals($lockedSession, $currentSession)
|| !hash_equals($lockedIp, clientIP())) {
jsonResponse([
'error' => 'This account is locked to its authorized session. Contact an administrator to unlock it.'
], 423);
}
}
function createUserSession(PDO $db, int $userId): void { function createUserSession(PDO $db, int $userId): void {
$sid = bin2hex(random_bytes(32)); $sid = bin2hex(random_bytes(32));
$db->prepare("DELETE FROM sessions WHERE user_id=?")->execute([$userId]); $now = time();
$ip = clientIP();
$db->beginTransaction();
try {
$lockStmt = $db->prepare("SELECT session_locked FROM users WHERE id=?");
$lockStmt->execute([$userId]);
$sessionLocked = (bool)$lockStmt->fetchColumn();
$db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)") $db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)")
->execute([$sid, $userId, clientIP(), time(), time()]); ->execute([$sid, $userId, $ip, $now, $now]);
$db->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([time(), $userId]); $db->prepare("DELETE FROM sessions WHERE user_id=? AND id<>?")->execute([$userId, $sid]);
if ($sessionLocked) {
$db->prepare("UPDATE users SET last_seen=?,session_lock_ip=?,session_lock_session_id=?,session_locked_at=?
WHERE id=?")->execute([$now, $ip, $sid, $now, $userId]);
} else {
$db->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([$now, $userId]);
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; $secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
setcookie('cyberchat_sid', $sid, [ setcookie('cyberchat_sid', $sid, [
'expires' => time() + ((int)getConfigVal('security.session_timeout_hours', 24) * 3600), 'expires' => time() + ((int)getConfigVal('security.session_timeout_hours', 24) * 3600),
-1
View File
@@ -13,7 +13,6 @@
"replies_enabled": true, "replies_enabled": true,
"reply_max_depth": 4, "reply_max_depth": 4,
"session_lock_ip": true, "session_lock_ip": true,
"session_lock_cookie": true,
"allow_guest": false "allow_guest": false
}, },
"archive": { "archive": {
+215 -62
View File
@@ -3,112 +3,265 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CyberChat Embed Example</title> <title>CyberChat - Embed Examples</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.4"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.5">
<style> <style>
html, body { html, body {
height: auto; min-height: 100%;
min-height: 100vh;
overflow: auto;
background: var(--bg0); background: var(--bg0);
padding: 32px 20px 48px;
font-family: var(--mono);
} }
.demo-page { max-width: 860px; margin: 0 auto; } body {
padding: 32px 20px 56px;
color: var(--t0);
font-family: var(--mono);
overflow-x: hidden;
overflow-y: auto;
}
.demo-page {
width: min(100%, 960px);
margin: 0 auto;
}
.demo-header { .demo-header {
margin-bottom: 30px;
text-align: center; text-align: center;
margin-bottom: 32px;
} }
.demo-header h1 { .demo-header h1 {
font-family: var(--disp);
font-size: clamp(18px, 4vw, 32px);
background: linear-gradient(90deg, var(--g), var(--b), var(--p));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 5px;
margin-bottom: 6px; margin-bottom: 6px;
background: linear-gradient(90deg, var(--g), var(--b), var(--p));
background-clip: text;
color: transparent;
font: 700 clamp(18px, 4vw, 32px) var(--disp);
letter-spacing: 5px;
} }
.demo-header p { .demo-header p,
font-size: 10px; .demo-note {
color: var(--t1); color: var(--t1);
letter-spacing: 2px; font-size: 10px;
letter-spacing: 1px;
line-height: 1.6;
}
.demo-section {
margin-top: 26px;
} }
.demo-label { .demo-label {
font-size: 10px;
color: var(--g);
letter-spacing: 2px;
margin-bottom: 8px; margin-bottom: 8px;
color: var(--g);
font-size: 10px;
letter-spacing: 2px;
}
.demo-frame {
width: 100%;
height: min(680px, 78vh);
min-height: 420px;
overflow: hidden;
border: 1px solid var(--bd);
border-radius: 6px;
background: var(--bg1);
box-shadow: 0 18px 60px rgba(0, 0, 0, .35);
} }
/* ── THE EMBED CONTAINER — set any size you want ── */
#chat-here { #chat-here {
width: 100%; width: 100%;
height: 580px; height: 100%;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 10px 0 12px;
}
.demo-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--b);
border-radius: 3px;
background: transparent;
color: var(--b);
font: 10px var(--mono);
letter-spacing: 1px;
text-decoration: none;
}
.demo-button:hover {
background: var(--b);
color: #000;
}
.demo-button.alt {
border-color: var(--v);
color: var(--v);
}
.demo-button.alt:hover {
background: var(--v);
color: #000;
} }
.demo-code { .demo-code {
background: var(--bg1); margin-top: 12px;
padding: 18px 20px;
overflow-x: auto;
border: 1px solid var(--bd); border: 1px solid var(--bd);
border-radius: 6px; border-radius: 6px;
padding: 18px 20px; background: var(--bg1);
margin-top: 24px;
font-size: 12px;
color: var(--t1); color: var(--t1);
line-height: 1.9; font-size: 12px;
overflow-x: auto; line-height: 1.75;
white-space: pre; white-space: pre;
} }
.demo-code .kw { color: var(--p); } .demo-code .kw { color: var(--p); }
.demo-code .str { color: var(--g); } .demo-code .str { color: var(--g); }
.demo-code .cm { color: var(--t2); font-style: italic; } .demo-code .cm { color: var(--t1); font-style: italic; }
.fullscreen-chat {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
grid-template-rows: 42px minmax(0, 1fr);
background: var(--bg0);
}
.fullscreen-chat[hidden] {
display: none;
}
.fullscreen-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--bd);
background: var(--bg1);
color: var(--g);
font-size: 10px;
letter-spacing: 1px;
}
.fullscreen-chat iframe {
width: 100%;
height: 100%;
border: 0;
background: var(--bg0);
}
@media (max-width: 520px) {
body { padding: 18px 10px 36px; }
.demo-frame { height: 72vh; min-height: 380px; }
.demo-code { padding: 14px; font-size: 10px; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="demo-page"> <main class="demo-page">
<div class="demo-header"> <header class="demo-header">
<h1>CYBERCHAT</h1> <h1>CYBERCHAT</h1>
<p>// EMBED EXAMPLE — DROP INTO ANY PAGE</p> <p>// INLINE, FULL-SCREEN, AND POPOUT EMBED EXAMPLES</p>
</header>
<section class="demo-section">
<div class="demo-label">INLINE EMBED</div>
<p class="demo-note">The chat owns the scrolling inside this fixed-height box. The surrounding page remains independent.</p>
<div class="demo-frame">
<div id="chat-here"></div>
</div>
<div class="demo-code"><span class="cm">&lt;!-- Include once in the host page --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"/chat/assets/css/cyberchat.css?v=20260609.5"</span><span class="kw">&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:680px; overflow:hidden"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="kw">&lt;script&gt;</span>window.CYBERCHAT_BASE = <span class="str">'/chat'</span>;<span class="kw">&lt;/script&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"/chat/assets/js/cyberchat-app.js?v=20260609.10"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span>CyberChat.init(<span class="str">'#my-chat'</span>);<span class="kw">&lt;/script&gt;</span></div>
</section>
<section class="demo-section">
<div class="demo-label">FULL-SCREEN EMBED</div>
<p class="demo-note">Open the standalone chat inside a full-viewport overlay. An iframe keeps the chat isolated from the host page CSS.</p>
<div class="demo-actions">
<button class="demo-button" id="open-fullscreen" type="button">OPEN FULL SCREEN</button>
</div>
<div class="demo-code"><span class="kw">&lt;div</span> id=<span class="str">"chat-overlay"</span> style=<span class="str">"position:fixed; inset:0; z-index:10000"</span><span class="kw">&gt;</span>
<span class="kw">&lt;iframe</span> src=<span class="str">"/chat/index.html?embed=fullscreen"</span>
title=<span class="str">"CyberChat"</span> style=<span class="str">"width:100%; height:100%; border:0"</span><span class="kw">&gt;&lt;/iframe&gt;</span>
<span class="kw">&lt;/div&gt;</span></div>
</section>
<section class="demo-section">
<div class="demo-label">POPOUT / POPUP EMBED</div>
<p class="demo-note">Launch the standalone chat in a resizable window. The login cookie is shared when it is opened on the same origin.</p>
<div class="demo-actions">
<a class="demo-button alt" id="open-popout" href="index.html?embed=popout"
target="cyberchat-popout" rel="noopener">OPEN POPOUT</a>
</div>
<div class="demo-code"><span class="kw">&lt;button</span> type=<span class="str">"button"</span> onclick=<span class="str">"openCyberChat()"</span><span class="kw">&gt;</span>Open chat<span class="kw">&lt;/button&gt;</span>
<span class="kw">&lt;script&gt;</span>
function openCyberChat() {
window.open(
<span class="str">'/chat/index.html?embed=popout'</span>,
<span class="str">'cyberchat-popout'</span>,
<span class="str">'popup=yes,width=480,height=720,resizable=yes,scrollbars=no'</span>
);
}
<span class="kw">&lt;/script&gt;</span></div>
</section>
</main>
<div class="fullscreen-chat" id="fullscreen-chat" hidden>
<div class="fullscreen-toolbar">
<span>CYBERCHAT // FULL SCREEN</span>
<button class="demo-button" id="close-fullscreen" type="button">CLOSE</button>
</div>
<iframe id="fullscreen-frame" title="CyberChat full-screen example"></iframe>
</div> </div>
<div class="demo-label">▸ LIVE EMBED (580px height, full width)</div> <script>
<!-- ── This is all you need in your page ── -->
<div id="chat-here"></div>
<div class="demo-code"><span class="cm">&lt;!-- 1. Google Fonts (optional but recommended) --&gt;</span>
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260609.4"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.8"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>);
<span class="kw">&lt;/script&gt;</span></div>
</div>
<script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-app.js?v=20260609.8"></script> <script src="assets/js/cyberchat-app.js?v=20260609.10"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here'); CyberChat.init('#chat-here');
const overlay = document.getElementById('fullscreen-chat');
const frame = document.getElementById('fullscreen-frame');
document.getElementById('open-fullscreen').addEventListener('click', function() {
if (!frame.src) frame.src = 'index.html?embed=fullscreen';
overlay.hidden = false;
document.body.style.overflow = 'hidden';
}); });
</script> document.getElementById('close-fullscreen').addEventListener('click', function() {
overlay.hidden = true;
document.body.style.overflow = '';
});
document.getElementById('open-popout').addEventListener('click', function(event) {
const popup = window.open(
'index.html?embed=popout',
'cyberchat-popout',
'popup=yes,width=480,height=720,resizable=yes,scrollbars=no'
);
if (popup) {
event.preventDefault();
popup.focus();
}
});
});
</script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="cc-fullpage-document">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
@@ -10,14 +10,14 @@
<title>Chat @ TyClifford.com</title> <title>Chat @ TyClifford.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.4"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.5">
</head> </head>
<body> <body class="cc-fullpage">
<div id="app"></div> <div id="app"></div>
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-app.js?v=20260609.8"></script> <script src="assets/js/cyberchat-app.js?v=20260609.10"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app'); CyberChat.init('#app');
-72
View File
@@ -4,33 +4,10 @@ function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') { if ($action === 'save_platform_services') {
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac'); $voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac');
if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = '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([ setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']), 'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'chat.replies_enabled' => !empty($_POST['replies_enabled']), 'chat.replies_enabled' => !empty($_POST['replies_enabled']),
'chat.reply_max_depth' => max(1, min(20, (int)($_POST['reply_max_depth'] ?? 4))), '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.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')), 'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')), 'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
@@ -182,55 +159,6 @@ function renderAdminPlatformTab(): void {
</div> </div>
</div></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="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
<div class="form-row3"> <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> <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;
}