query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
foreach ([
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT',
'voice_mime' => 'TEXT',
'voice_duration' => 'REAL',
] as $name => $definition) {
if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition");
}
}
function adminPublicMessagePredicate(PDO $d, string $alias = ''): string {
$prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
return isset(tableColumns($d, 'messages')['group_id']) ? "{$prefix}group_id IS NULL" : '1=1';
}
function archiveDB(string $year, string $month, string $day): ?PDO {
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
if (!file_exists($path)) return null;
$a = new PDO('sqlite:' . $path);
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
ensureAdminMessageColumns($a);
return $a;
}
function createArchiveDB(string $year, string $month, string $day): PDO {
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
$dir = dirname($path);
ensureWritableDirectory($dir, 'Archive');
$a = new PDO('sqlite:' . $path);
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$a->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
color TEXT NOT NULL,
message TEXT NOT NULL,
message_type TEXT NOT NULL DEFAULT 'text',
voice_file TEXT,
voice_mime TEXT,
voice_duration REAL,
created_at INTEGER NOT NULL,
day_key TEXT NOT NULL
)");
ensureAdminMessageColumns($a);
$a->exec("CREATE TABLE IF NOT EXISTS archive_meta (
key TEXT PRIMARY KEY,
value TEXT
)");
return $a;
}
function esc(mixed $v): string { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); }
function fmtTime(int $ts): string { return date('Y-m-d H:i:s', $ts); }
function ago(int $ts): string {
$d = time() - $ts; if ($d < 60) return $d . 's ago';
if ($d < 3600) return floor($d/60) . 'm ago';
if ($d < 86400) return floor($d/3600) . 'h ago';
return floor($d/86400) . 'd ago';
}
function flash(string $msg, string $type = 'ok'): void {
$_SESSION['flash'] = ['msg' => $msg, 'type' => $type];
}
function getFlash(): ?array {
$f = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return $f;
}
function redirect(string $qs = ''): never {
header('Location: admin.php' . ($qs ? '?' . $qs : '')); exit;
}
function csrfToken(): string {
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));
return $_SESSION['csrf'];
}
function csrfCheck(): void {
if (($_POST['csrf'] ?? '') !== ($_SESSION['csrf'] ?? '')) {
flash('Invalid CSRF token.', 'err'); redirect();
}
}
function deleteRecording(?string $filename): void {
if (!$filename || basename($filename) !== $filename) return;
$dir = storagePath((string)cfgVal('voice.upload_dir', 'uploads/voice'));
$path = $dir . '/' . $filename;
if (is_file($path)) unlink($path);
}
function deleteRecordingsForRows(array $rows): void {
foreach ($rows as $row) deleteRecording($row['voice_file'] ?? null);
}
function voiceFileUrl(?string $filename): string {
if (!$filename || basename($filename) !== $filename) return '';
return trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($filename);
}
function voiceFileSize(?string $filename): ?int {
if (!$filename || basename($filename) !== $filename) return null;
$path = storagePath((string)cfgVal('voice.upload_dir', 'uploads/voice')) . '/' . $filename;
return is_file($path) ? filesize($path) : null;
}
function refreshArchiveMeta(PDO $archive): void {
$archive->exec("CREATE TABLE IF NOT EXISTS archive_meta (
key TEXT PRIMARY KEY,
value TEXT
)");
$count = (int)$archive->query(
"SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($archive)
)->fetchColumn();
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)");
$meta->execute(['archived_at', (string)time()]);
$meta->execute(['message_count', (string)$count]);
}
// ── Auth ──────────────────────────────────────────────────────────────────────
$isLoggedIn = !empty($_SESSION['admin_auth']);
if (isset($_POST['admin_login'])) {
if ($_POST['admin_pass'] === ADMIN_PASSWORD) {
$_SESSION['admin_auth'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
redirect();
} else {
$loginError = 'Incorrect password.';
}
}
if (isset($_POST['admin_logout'])) {
session_destroy(); redirect();
}
// ── POST actions (require auth) ───────────────────────────────────────────────
if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrfCheck();
$act = $_POST['action'] ?? '';
if (in_array($act, ['save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
try {
$message = handleAdminPlatformAction($act);
flash($message ?: 'Platform action completed.');
} catch (Throwable $e) {
flash($e->getMessage(), 'err');
}
redirect('tab=platform');
}
// ── Messages ──────────────────────────────────────────────────────────────
if ($act === 'archive_voice_clip') {
$id = (int)($_POST['msg_id'] ?? 0);
$live = db();
$stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice' AND "
. adminPublicMessagePredicate($live));
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
flash('Voice clip not found.', 'err');
redirect($_POST['return_qs'] ?? 'tab=voice');
}
$day = (string)$row['day_key'];
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) {
flash('Voice clip has an invalid archive date.', 'err');
redirect($_POST['return_qs'] ?? 'tab=voice');
}
[$y, $m, $d] = explode('-', $day);
try {
$adb = createArchiveDB($y, $m, $d);
$adb->beginTransaction();
try {
$existing = $adb->prepare("SELECT voice_file FROM messages WHERE id = ?");
$existing->execute([$id]);
$existingFile = $existing->fetchColumn();
if ($existingFile !== false && $existingFile !== $row['voice_file']) {
throw new RuntimeException('Archive already contains a different message with this ID.');
}
if ($existingFile === false) {
$insert = $adb->prepare("INSERT INTO messages
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
$insert->execute([
$row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'],
$row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'],
$row['created_at'], $row['day_key']
]);
}
refreshArchiveMeta($adb);
$adb->commit();
} catch (Throwable $e) {
if ($adb->inTransaction()) $adb->rollBack();
throw $e;
}
$delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice' AND "
. adminPublicMessagePredicate($live));
$delete->execute([$id]);
if ($delete->rowCount() !== 1) {
throw new RuntimeException('The live clip changed before it could be removed.');
}
flash("Voice clip #$id archived to $day.");
} catch (Throwable $e) {
flash('Could not archive voice clip: ' . $e->getMessage(), 'err');
}
redirect($_POST['return_qs'] ?? 'tab=voice');
}
if ($act === 'delete_message') {
$id = (int)$_POST['msg_id'];
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
if ($src === 'live') {
$live = db();
$public = adminPublicMessagePredicate($live);
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ? AND $public");
$row->execute([$id]);
deleteRecordingsForRows($row->fetchAll());
$live->prepare("DELETE FROM messages WHERE id = ? AND $public")->execute([$id]);
flash("Message #$id deleted.");
} else {
[$_, $day] = explode(':', $src, 2);
[$y,$m,$d] = explode('-', $day);
$adb = archiveDB($y, $m, $d);
if ($adb) {
$row = $adb->prepare("SELECT voice_file FROM messages WHERE id = ?");
$row->execute([$id]);
deleteRecordingsForRows($row->fetchAll());
$adb->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
refreshArchiveMeta($adb);
flash("Archive message #$id deleted.");
}
}
redirect($_POST['return_qs'] ?? '');
}
if ($act === 'edit_message') {
$id = (int)$_POST['msg_id'];
$txt = trim($_POST['msg_text']);
$username = trim($_POST['msg_username'] ?? '');
$src = $_POST['msg_src'] ?? 'live';
if ($txt === '') { flash('Message cannot be empty.', 'err'); redirect($_POST['return_qs'] ?? ''); }
if ($username === '' || !preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) {
flash('A valid displayed sender is required.', 'err'); redirect($_POST['return_qs'] ?? '');
}
if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) {
flash('Displayed sender is too long.', 'err'); redirect($_POST['return_qs'] ?? '');
}
if ($src === 'live') {
$live = db();
$live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ? AND "
. adminPublicMessagePredicate($live))->execute([$txt, $username, $id]);
flash("Message #$id updated.");
} else {
[$_, $day] = explode(':', $src, 2);
[$y,$m,$d] = explode('-', $day);
$adb = archiveDB($y, $m, $d);
if ($adb) {
$adb->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")->execute([$txt, $username, $id]);
flash("Archive message #$id updated.");
}
}
redirect($_POST['return_qs'] ?? '');
}
if ($act === 'delete_messages_bulk') {
$ids = array_map('intval', $_POST['msg_ids'] ?? []);
if ($ids) {
$live = db();
$public = adminPublicMessagePredicate($live);
$ph = implode(',', array_fill(0, count($ids), '?'));
$rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph) AND $public");
$rows->execute($ids);
deleteRecordingsForRows($rows->fetchAll());
$live->prepare("DELETE FROM messages WHERE id IN ($ph) AND $public")->execute($ids);
flash(count($ids) . ' message(s) deleted.');
}
redirect($_POST['return_qs'] ?? '');
}
if ($act === 'delete_day_live') {
$day = $_POST['day_key'];
$live = db();
$public = adminPublicMessagePredicate($live);
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ? AND $public");
$rows->execute([$day]);
deleteRecordingsForRows($rows->fetchAll());
$st = $live->prepare("DELETE FROM messages WHERE day_key = ? AND $public");
$st->execute([$day]);
flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
redirect('tab=messages');
}
if ($act === 'delete_archive_day') {
$day = $_POST['day'];
[$y,$m,$d] = explode('-', $day);
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$y,$m,$d], $tpl));
if (file_exists($path)) {
$adb = archiveDB($y, $m, $d);
if ($adb) deleteRecordingsForRows($adb->query("SELECT voice_file FROM messages")->fetchAll());
$adb = null;
unlink($path);
flash("Archive for $day deleted.");
}
else flash("Archive file not found.", 'err');
redirect('tab=archives');
}
if ($act === 'clear_all_live') {
$live = db();
$public = adminPublicMessagePredicate($live);
deleteRecordingsForRows($live->query("SELECT voice_file FROM messages WHERE $public")->fetchAll());
$live->exec("DELETE FROM messages WHERE $public");
flash("All live messages cleared.");
redirect('tab=messages');
}
// ── Users ─────────────────────────────────────────────────────────────────
if ($act === 'edit_user') {
$id = (int)$_POST['user_id'];
$username = trim($_POST['username']);
$color = trim($_POST['color']);
$newpass = trim($_POST['new_password']);
$manualPlanId = max(0, (int)($_POST['manual_plan_id'] ?? 0));
$errors = [];
if ($username === '') $errors[] = 'Username required.';
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Username: letters, numbers, _ and - only.';
if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) $errors[] = 'Username too long.';
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Invalid color hex.';
if ($manualPlanId > 0 && !planById($manualPlanId)) $errors[] = 'Invalid tier override.';
if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); }
// Check username collision
$existing = db()->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?");
$existing->execute([$username, $id]);
if ($existing->fetchColumn()) {
flash('Username already taken.', 'err'); redirect('tab=users');
}
db()->prepare("UPDATE users SET username = ?, color = ?, manual_plan_id = ? WHERE id = ?")
->execute([$username, $color, $manualPlanId ?: null, $id]);
// Also update username in messages (denormalised)
db()->prepare("UPDATE messages SET username = ?, color = ? WHERE user_id = ?")->execute([$username, $color, $id]);
if ($newpass !== '') {
$hash = password_hash($newpass, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]);
db()->prepare("UPDATE users SET password_hash = ? WHERE id = ?")->execute([$hash, $id]);
}
flash("User #$id updated, including tier assignment.");
redirect('tab=users');
}
if ($act === 'delete_user') {
$id = (int)$_POST['user_id'];
// Cascade deletes sessions; messages are kept (username preserved in row)
db()->prepare("DELETE FROM users WHERE id = ?")->execute([$id]);
flash("User #$id deleted. Their messages are preserved.");
redirect('tab=users');
}
if ($act === 'kick_user') {
$id = (int)$_POST['user_id'];
db()->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$id]);
flash("User #$id sessions terminated (kicked).");
redirect('tab=users');
}
if ($act === 'create_user') {
$username = trim($_POST['username']);
$password = trim($_POST['password']);
$color = trim($_POST['color']);
$palette = cfgVal('colors.user_palette', ['#00ff9f']);
if (!$color) $color = $palette[array_rand($palette)];
$errors = [];
if ($username === '') $errors[] = 'Username required.';
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Invalid username characters.';
if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) $errors[] = 'Username too long.';
if (strlen($password) < (int)cfgVal('security.min_password_length', 4)) $errors[] = 'Password too short.';
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Invalid color hex.';
if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); }
try {
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]);
$freeId = (int)db()->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn();
db()->prepare("INSERT INTO users (username,password_hash,color,plan_id,created_at) VALUES (?,?,?,?,?)")
->execute([$username, $hash, $color, $freeId ?: null, time()]);
flash("User '$username' created.");
} catch (Exception $e) { flash('Username already exists.', 'err'); }
redirect('tab=users');
}
// ── Sessions ──────────────────────────────────────────────────────────────
if ($act === 'delete_session') {
db()->prepare("DELETE FROM sessions WHERE id = ?")->execute([$_POST['session_id']]);
flash("Session terminated.");
redirect('tab=sessions');
}
if ($act === 'clear_expired_sessions') {
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
$st = db()->prepare("DELETE FROM sessions WHERE last_active < ?"); $st->execute([time() - $timeout]);
flash($st->rowCount() . " expired session(s) cleared.");
redirect('tab=sessions');
}
if ($act === 'clear_all_sessions') {
db()->exec("DELETE FROM sessions");
flash("All sessions cleared — everyone is logged out.");
redirect('tab=sessions');
}
// ── Config ────────────────────────────────────────────────────────────────
if ($act === 'save_config') {
$raw = $_POST['config_json'] ?? '';
$parsed = json_decode($raw, true);
if ($parsed === null) { flash('Invalid JSON: ' . json_last_error_msg(), 'err'); redirect('tab=config'); }
file_put_contents(CONFIG_FILE, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
flash('config.json saved.');
redirect('tab=config');
}
}
// ── Data fetching ─────────────────────────────────────────────────────────────
$tab = $_GET['tab'] ?? 'dashboard';
$stats = [];
if ($isLoggedIn) {
try {
$live = db();
$public = adminPublicMessagePredicate($live);
$stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn();
$stats['messages'] = $live->query("SELECT COUNT(*) FROM messages WHERE $public")->fetchColumn();
$stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice' AND $public")->fetchColumn();
$stats['sessions'] = $live->query("SELECT COUNT(*) FROM sessions")->fetchColumn();
$stats['today'] = $live->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "' AND $public")->fetchColumn();
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
}
// Archive list
$archives = [];
if ($isLoggedIn) {
$archDir = storagePath((string)cfgVal('archive.archive_dir', 'archive'));
if (is_dir($archDir)) {
foreach (glob($archDir . '/*/*/*.sqlite') as $f) {
if (preg_match('/(\d{4})\/(\d{2})\/(\d{2})\.sqlite$/', $f, $m)) {
$day = "{$m[1]}-{$m[2]}-{$m[3]}";
$sz = filesize($f);
$cnt = 0;
try {
$x = new PDO('sqlite:'.$f);
$cnt = $x->query("SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($x))->fetchColumn();
} catch(Exception $e) {}
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
}
}
usort($archives, fn($a,$b) => strcmp($b['day'], $a['day']));
}
}
$flash = getFlash();
?>
CyberChat Admin
// = strtoupper(esc($tab)) ?>
= $flash['type']==='ok' ? '✓' : '⚠' ?> = esc($flash['msg']) ?>
DASHBOARD
// system overview
= esc($stats['users']) ?>
Users
= esc($stats['messages']) ?>
Live Messages
= esc($stats['voice']) ?>
Live Voice Clips
= esc($stats['today']) ?>
Today
= esc($stats['sessions']) ?>
Sessions
= esc($stats['active']) ?>
Active Now
query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id
WHERE " . adminPublicMessagePredicate($live, 'm') . " ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
if (empty($recent)): ?>
NO MESSAGES YET
User Message Time
= esc($r['username']) ?>
= ($r['message_type'] ?? 'text') === 'voice' ? 'VOICE CLIP · ' . esc(round((float)$r['voice_duration'], 1)) . 's' : esc($r['message']) ?>
= ago((int)$r['created_at']) ?>
query("SELECT * FROM users ORDER BY created_at DESC LIMIT 8")->fetchAll();
if (empty($recUsers)): ?>
NO USERS YET
Username Color Joined
= esc($u['username']) ?>
= ago((int)$u['created_at']) ?>
LIVE MESSAGES
// today's active chat — = esc(date('Y-m-d')) ?>
prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
// Days available
$days = $live->query("SELECT DISTINCT day_key FROM messages WHERE "
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
?>
// = count($msgs) ?> MESSAGE(S)
0 selected
DELETE SELECTED
CLEAR
VOICE CLIPS
// play, archive, and delete walkie-talkie recordings
prepare("SELECT * FROM messages WHERE " . implode(' AND ', $vWhere) . " ORDER BY created_at DESC LIMIT 500");
$vStmt->execute($vParams);
foreach ($vStmt->fetchAll() as $row) {
$row['_source'] = 'live';
$voiceRows[] = $row;
}
}
if ($vSource !== 'live') {
foreach ($archives as $arc) {
if ($vDay !== '' && $arc['day'] !== $vDay) continue;
try {
$vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']);
if (!$vAdb) continue;
$vSql = "SELECT * FROM messages WHERE message_type = 'voice' AND "
. adminPublicMessagePredicate($vAdb);
$vParams = [];
if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
$vSql .= ' ORDER BY created_at DESC LIMIT 500';
$vStmt = $vAdb->prepare($vSql);
$vStmt->execute($vParams);
foreach ($vStmt->fetchAll() as $row) {
$row['_source'] = 'archive';
$voiceRows[] = $row;
}
} catch (Throwable $e) {
// Keep the rest of the archive list usable if one file is damaged.
}
}
}
usort($voiceRows, fn($a, $b) => ((int)$b['created_at'] <=> (int)$a['created_at']));
$voiceRows = array_slice($voiceRows, 0, 500);
$live = db();
$voiceDays = array_unique(array_merge(
$live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' AND "
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
array_column($archives, 'day')
));
rsort($voiceDays);
$voiceReturn = http_build_query(['tab'=>'voice', 'vu'=>$vUser, 'vd'=>$vDay, 'vs'=>$vSource]);
?>
// = count($voiceRows) ?> VOICE CLIP(S)
MAX 500 RESULTS
NO VOICE CLIPS FOUND
ID Source Day User Recording File Time Actions
#= (int)$clip['id'] ?>
= strtoupper(esc($clipSource)) ?>
= esc($clip['day_key']) ?>
= esc($clip['username']) ?>
RECORDING MISSING
= esc(strtoupper(pathinfo((string)$clip['voice_file'], PATHINFO_EXTENSION))) ?>
· = esc(round((float)$clip['voice_duration'], 1)) ?>s
· = number_format($clipSize / 1024, 1) ?> KB
= fmtTime((int)$clip['created_at']) ?>
ARCHIVES
// daily chat logs — = count($archives) ?> archive(s)
prepare($asql); $astmt->execute($aparams);
$archMsgs = $astmt->fetchAll();
}
}
?>
= esc($viewDay) ?>
// archive day view
// = count($archMsgs) ?> MESSAGE(S)
NO MESSAGES FOUND
ID User Message Time Actions
#= $msg['id'] ?>
= esc($msg['username']) ?>
VOICE · = esc(round((float)$msg['voice_duration'], 1)) ?>s
= esc($msg['message']) ?>
= fmtTime((int)$msg['created_at']) ?>
,= json_encode('archive:' . $viewDay) ?>,= json_encode($msg['message'], JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_TAG|JSON_HEX_QUOT) ?>,= json_encode($msg['username']) ?>,= json_encode('tab=archives&day=' . urlencode($viewDay)) ?>)'>
EDIT
// ARCHIVED DAYS
NO ARCHIVES YET
Date Messages File Size Actions
= esc($arc['day']) ?>
= $arc['count'] ?> msgs
= number_format($arc['size'] / 1024, 1) ?> KB
USERS
// manage accounts and credentials
query("
SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name,
(SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id AND "
. adminPublicMessagePredicate($live, 'm') . ") as msg_count,
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
FROM users u
LEFT JOIN plans p ON p.id=u.plan_id
LEFT JOIN plans mp ON mp.id=u.manual_plan_id
ORDER BY u.created_at DESC
")->fetchAll();
?>
// = count($allUsers) ?> USER(S)
NO USERS YET
ID Username Tier Color Messages
Sessions Last Seen Joined Actions
#= $u['id'] ?>
= esc($u['username']) ?>
= esc($u['manual_plan_name'] ?: $u['billing_plan_name'] ?: 'Free') ?>
= $u['manual_plan_id'] ? 'ADMIN' : 'BILLING' ?>
= esc($u['color']) ?>
= $u['msg_count'] ?>
= $u['sess_count'] ?>
= $u['last_seen'] ? ago((int)$u['last_seen']) : 'Never' ?>
= fmtTime((int)$u['created_at']) ?>
,= json_encode($u['username']) ?>,= json_encode($u['color']) ?>,= json_encode((int)($u['manual_plan_id'] ?? 0)) ?>)'>
EDIT
SESSIONS
// active logins and session management
query("
SELECT s.*, u.username, u.color
FROM sessions s JOIN users u ON u.id = s.user_id
ORDER BY s.last_active DESC
")->fetchAll();
?>
// = count($allSess) ?> SESSION(S)
NO SESSIONS
Token User IP Status
Last Active Created Actions
$timeout;
?>
= substr(esc($s['id']),0,16) ?>…
= esc($s['username']) ?>
= esc($s['ip']) ?>
= $expired ? 'EXPIRED' : 'ACTIVE' ?>
= ago((int)$s['last_active']) ?>
= fmtTime((int)$s['created_at']) ?>
CONFIGURATION
// edit config.json live — changes take effect immediately
// config.json
= esc(CONFIG_FILE) ?>
// QUICK REFERENCE
Key Type Description
chat.max_message_length integer Max chars per message
chat.max_username_length integer Max callsign length
chat.poll_interval_ms integer Client poll interval (ms)
chat.poll_timeout_ms integer Abort and retry a stalled poll after this many milliseconds
chat.poll_max_backoff_ms integer Maximum delay after repeated poll failures
chat.max_rendered_messages integer Maximum chat rows retained in the browser
chat.session_lock_ip bool Lock session to IP
voice.enabled bool Show voice recording controls
voice.max_duration_seconds integer Maximum voice clip duration
voice.max_upload_bytes integer Maximum recording upload size
voice.auto_play_default bool Queue and play incoming clips sequentially by default
security.min_password_length integer Min password chars
security.session_timeout_hours integer Session expiry in hours
security.bcrypt_cost integer bcrypt work factor (10–14)
ui.app_title string Header title text
colors.user_palette array Hex colors for new users
// EDIT MESSAGE
✕
Displayed Sender
Message Text
Max = cfgVal('chat.max_message_length',500) ?> characters