- 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
+57 -4
View File
@@ -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),