- 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:
+15
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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']);
|
||||||
|
|||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
+203
-50
@@ -3,111 +3,264 @@
|
|||||||
<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>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="demo-label">▸ LIVE EMBED (580px height, full width)</div>
|
<section class="demo-section">
|
||||||
|
<div class="demo-label">INLINE EMBED</div>
|
||||||
<!-- ── This is all you need in your page ── -->
|
<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 id="chat-here"></div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-code"><span class="cm"><!-- Include once in the host page --></span>
|
||||||
|
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"/chat/assets/css/cyberchat.css?v=20260609.5"</span><span class="kw">></span>
|
||||||
|
|
||||||
<div class="demo-code"><span class="cm"><!-- 1. Google Fonts (optional but recommended) --></span>
|
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:680px; overflow:hidden"</span><span class="kw">></div></span>
|
||||||
<span class="kw"><link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">></span>
|
|
||||||
|
|
||||||
<span class="cm"><!-- 2. CyberChat stylesheet --></span>
|
<span class="kw"><script></span>window.CYBERCHAT_BASE = <span class="str">'/chat'</span>;<span class="kw"></script></span>
|
||||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260609.4"</span><span class="kw">></span>
|
<span class="kw"><script</span> src=<span class="str">"/chat/assets/js/cyberchat-app.js?v=20260609.10"</span><span class="kw">></script></span>
|
||||||
|
<span class="kw"><script></span>CyberChat.init(<span class="str">'#my-chat'</span>);<span class="kw"></script></span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<span class="cm"><!-- 3. Container with any dimensions --></span>
|
<section class="demo-section">
|
||||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
<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"><div</span> id=<span class="str">"chat-overlay"</span> style=<span class="str">"position:fixed; inset:0; z-index:10000"</span><span class="kw">></span>
|
||||||
|
<span class="kw"><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">></iframe></span>
|
||||||
|
<span class="kw"></div></span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<span class="cm"><!-- 4. Script + init --></span>
|
<section class="demo-section">
|
||||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.8"</span><span class="kw">></script></span>
|
<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"><button</span> type=<span class="str">"button"</span> onclick=<span class="str">"openCyberChat()"</span><span class="kw">></span>Open chat<span class="kw"></button></span>
|
||||||
<span class="kw"><script></span>
|
<span class="kw"><script></span>
|
||||||
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
function openCyberChat() {
|
||||||
CyberChat.init(<span class="str">'#my-chat'</span>);
|
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"></script></span></div>
|
<span class="kw"></script></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>
|
||||||
|
|
||||||
<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('#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';
|
||||||
|
});
|
||||||
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+4
-4
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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