From 36c488ca1ea4884d358613a5571f47f522d8a715 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Tue, 9 Jun 2026 18:54:25 -0400 Subject: [PATCH] - 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. --- CHANGELOG.md | 16 ++- README.md | 18 ++- admin.php | 17 +++ api/auth.php | 11 +- assets/css/cyberchat.css | 15 +- assets/js/cyberchat-app.js | 12 +- bootstrap.php | 61 +++++++- config.json | 1 - embed-example.html | 285 ++++++++++++++++++++++++++++--------- index.html | 8 +- lib/admin_platform.php | 72 ---------- lib/admin_spam.php | 252 ++++++++++++++++++++++++++++++++ 12 files changed, 599 insertions(+), 169 deletions(-) create mode 100644 lib/admin_spam.php diff --git a/CHANGELOG.md b/CHANGELOG.md index eecac3b..361d957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,21 @@ CyberChat `2.1.0` is the current baseline release. The earlier `2.0.0` commit is ## [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 diff --git a/README.md b/README.md index d3e503a..2760550 100644 --- a/README.md +++ b/README.md @@ -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 - Case-insensitive unique usernames using letters, numbers, underscores, and hyphens - 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 - Email verification by six-digit code or verification link - 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. -Dashboard controls under **Plans & Platform > Registration / Login CAPTCHA** configure: +Dashboard controls under **Spam Control > Registration / Login CAPTCHA** configure: - Registration and login protection independently - 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 | | Users | Create users, edit credentials/colors, assign tiers, kick, delete | | 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 | ## Configuration Families @@ -424,18 +427,18 @@ The diagnostic endpoint exposes deployment details and should be restricted or r ## Embedding ```html - -
+ +
- + ``` -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 @@ -458,6 +461,7 @@ api/stripe-webhook.php Stripe event synchronization assets/css/cyberchat.css Cyberpunk and light interface themes assets/js/cyberchat-app.js Browser chat, audio player, polling, and account UI 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/mysql_mirror.php Optional SQLite-to-MySQL mirror lib/stripe.php Stripe REST client and subscription sync diff --git a/admin.php b/admin.php index dedb817..151b199 100644 --- a/admin.php +++ b/admin.php @@ -13,6 +13,7 @@ define('CONFIG_FILE', ROOT_DIR . '/config.json'); define('ADMIN_VERSION', '2.2.0'); require_once ROOT_DIR . '/bootstrap.php'; require_once ROOT_DIR . '/lib/admin_platform.php'; +require_once ROOT_DIR . '/lib/admin_spam.php'; // ─── CHANGE THIS PASSWORD ──────────────────────────────────────────────────── define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123')); @@ -200,6 +201,16 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { 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 ────────────────────────────────────────────────────────────── if ($act === 'archive_voice_clip') { $id = (int)($_POST['msg_id'] ?? 0); @@ -888,6 +899,9 @@ td.actions{white-space:nowrap;width:1px} Sessions + + !Spam Control + Plans & Platform @@ -1695,6 +1709,9 @@ td.actions{white-space:nowrap;width:1px} + + + diff --git a/api/auth.php b/api/auth.php index 9949fd4..597e0be 100644 --- a/api/auth.php +++ b/api/auth.php @@ -64,15 +64,7 @@ function loginUser(): never { jsonResponse(['error' => 'Invalid username or password'], 401); } - if (getConfigVal('chat.session_lock_ip', true) || getConfigVal('chat.session_lock_cookie', true)) { - $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); - } - } + assertUserSessionLoginAllowed($db, (int)$user['id']); if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) { 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]); jsonResponse(['error' => 'Invalid or expired login code'], 401); } + assertUserSessionLoginAllowed($db, (int)$row['user_id']); $db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]); createUserSession($db, (int)$row['user_id']); trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']); diff --git a/assets/css/cyberchat.css b/assets/css/cyberchat.css index de0219a..b56ce37 100644 --- a/assets/css/cyberchat.css +++ b/assets/css/cyberchat.css @@ -67,7 +67,7 @@ input, button { font-family: inherit; } flex-direction: column; width: 100%; height: 100%; - min-height: 360px; + min-height: 0; background: var(--bg0); color: var(--t0); font-family: var(--ui); @@ -76,6 +76,8 @@ input, button { font-family: inherit; } border: 1px solid var(--bd); border-radius: var(--r2); overflow: hidden; + overscroll-behavior: contain; + contain: layout paint; position: relative; -webkit-font-smoothing: antialiased; } @@ -222,6 +224,7 @@ input, button { font-family: inherit; } /* ─── Body area ──────────────────────────────────────────────────────────── */ .cc-body { flex: 1; + min-height: 0; display: flex; flex-direction: column; overflow: hidden; @@ -629,7 +632,10 @@ input, button { font-family: inherit; } /* Messages */ .cc-messages { flex: 1; + min-height: 0; overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; display: flex; flex-direction: column; padding: 6px 0 4px; @@ -1239,14 +1245,15 @@ input, button { font-family: inherit; } .cc-auth-wrap { align-items: flex-start; } } -/* ─── Full-page (index.html) embed ───────────────────────────────────────── */ -html, body { +/* ─── Full-page (index.html) shell ───────────────────────────────────────── */ +html.cc-fullpage-document, +body.cc-fullpage { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } -#app { +body.cc-fullpage #app { width: 100%; height: 100%; } diff --git a/assets/js/cyberchat-app.js b/assets/js/cyberchat-app.js index 2e5c842..0824369 100644 --- a/assets/js/cyberchat-app.js +++ b/assets/js/cyberchat-app.js @@ -55,6 +55,7 @@ }); const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' })); if (!response.ok && !data.error) data.error = 'Request failed'; + if (data && typeof data === 'object') data.http_status = response.status; return data; } function feature(key, fallback = false) { @@ -631,6 +632,11 @@ if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') { 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)) { throw new Error(result?.error || 'Invalid poll response'); } @@ -798,7 +804,11 @@ const parentId = Number(event.currentTarget.dataset.parentId); const parent = $(`.cc-msg[data-id="${parentId}"]`); 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'); setTimeout(() => parent.classList.remove('cc-msg-focus'), 1200); }); diff --git a/bootstrap.php b/bootstrap.php index 3ce2571..d3c71f9 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -238,6 +238,10 @@ function initDB(PDO $db): void { subscription_period_end INTEGER, cancel_at_period_end 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')), last_seen INTEGER )"); @@ -252,6 +256,10 @@ function initDB(PDO $db): void { 'subscription_period_end' => 'INTEGER', 'cancel_at_period_end' => '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 ( @@ -685,6 +693,16 @@ function authUser(bool $required = true): ?array { if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401); 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]); return $user; } @@ -695,12 +713,47 @@ function authRequired(): array { 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 { $sid = bin2hex(random_bytes(32)); - $db->prepare("DELETE FROM sessions WHERE user_id=?")->execute([$userId]); - $db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)") - ->execute([$sid, $userId, clientIP(), time(), time()]); - $db->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([time(), $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 (?,?,?,?,?)") + ->execute([$sid, $userId, $ip, $now, $now]); + $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'; setcookie('cyberchat_sid', $sid, [ 'expires' => time() + ((int)getConfigVal('security.session_timeout_hours', 24) * 3600), diff --git a/config.json b/config.json index e7277a5..69844fa 100644 --- a/config.json +++ b/config.json @@ -13,7 +13,6 @@ "replies_enabled": true, "reply_max_depth": 4, "session_lock_ip": true, - "session_lock_cookie": true, "allow_guest": false }, "archive": { diff --git a/embed-example.html b/embed-example.html index 3ebb276..65e4e41 100644 --- a/embed-example.html +++ b/embed-example.html @@ -3,112 +3,265 @@ - CyberChat — Embed Example + CyberChat - Embed Examples - - + + -
-
-

CYBERCHAT

-

// EMBED EXAMPLE — DROP INTO ANY PAGE

+
+
+

CYBERCHAT

+

// INLINE, FULL-SCREEN, AND POPOUT EMBED EXAMPLES

+
+ +
+
INLINE EMBED
+

The chat owns the scrolling inside this fixed-height box. The surrounding page remains independent.

+
+
+
+
<!-- Include once in the host page --> +<link rel="stylesheet" href="/chat/assets/css/cyberchat.css?v=20260609.5"> + +<div id="my-chat" style="width:100%; height:680px; overflow:hidden"></div> + +<script>window.CYBERCHAT_BASE = '/chat';</script> +<script src="/chat/assets/js/cyberchat-app.js?v=20260609.10"></script> +<script>CyberChat.init('#my-chat');</script>
+
+ +
+
FULL-SCREEN EMBED
+

Open the standalone chat inside a full-viewport overlay. An iframe keeps the chat isolated from the host page CSS.

+
+ +
+
<div id="chat-overlay" style="position:fixed; inset:0; z-index:10000"> + <iframe src="/chat/index.html?embed=fullscreen" + title="CyberChat" style="width:100%; height:100%; border:0"></iframe> +</div>
+
+ +
+
POPOUT / POPUP EMBED
+

Launch the standalone chat in a resizable window. The login cookie is shared when it is opened on the same origin.

+
+
<button type="button" onclick="openCyberChat()">Open chat</button> +<script> +function openCyberChat() { + window.open( + '/chat/index.html?embed=popout', + 'cyberchat-popout', + 'popup=yes,width=480,height=720,resizable=yes,scrollbars=no' + ); +} +</script>
+
+
+ + -
▸ LIVE EMBED (580px height, full width)
+ + + - - + 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'; + }); + 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(); + } + }); + }); + diff --git a/index.html b/index.html index 9214d0b..28abb44 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + @@ -10,14 +10,14 @@ Chat @ TyClifford.com - + - +
- +