Database not found at: ' . esc($path) . '');
$d = new PDO('sqlite:' . $path);
$d->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$d->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
ensureAdminMessageColumns($d);
return $d;
}
function ensureAdminMessageColumns(PDO $d): void {
$columns = [];
foreach ($d->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 archiveDB(string $year, string $month, string $day): ?PDO {
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
$path = ROOT_DIR . '/' . ltrim(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 = ROOT_DIR . '/' . ltrim(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl), '/');
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
throw new RuntimeException('Could not create archive directory.');
}
$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 = ROOT_DIR . '/' . trim((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 = ROOT_DIR . '/' . trim((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")->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'] ?? '';
// ── Messages ──────────────────────────────────────────────────────────────
if ($act === 'archive_voice_clip') {
$id = (int)($_POST['msg_id'] ?? 0);
$stmt = db()->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
$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 = db()->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
$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') {
$row = db()->prepare("SELECT voice_file FROM messages WHERE id = ?");
$row->execute([$id]);
deleteRecordingsForRows($row->fetchAll());
db()->prepare("DELETE FROM messages WHERE id = ?")->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') {
db()->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")->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) {
$ph = implode(',', array_fill(0, count($ids), '?'));
$rows = db()->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)");
$rows->execute($ids);
deleteRecordingsForRows($rows->fetchAll());
db()->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
flash(count($ids) . ' message(s) deleted.');
}
redirect($_POST['return_qs'] ?? '');
}
if ($act === 'delete_day_live') {
$day = $_POST['day_key'];
$rows = db()->prepare("SELECT voice_file FROM messages WHERE day_key = ?");
$rows->execute([$day]);
deleteRecordingsForRows($rows->fetchAll());
$st = db()->prepare("DELETE FROM messages WHERE day_key = ?"); $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 = ROOT_DIR . '/' . ltrim(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') {
deleteRecordingsForRows(db()->query("SELECT voice_file FROM messages")->fetchAll());
db()->exec("DELETE FROM messages");
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']);
$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 ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); }
// Check username collision
$existing = db()->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?")->execute([$username, $id]);
if (db()->query("SELECT COUNT(*) FROM users WHERE username = '$username' AND id != $id")->fetchColumn() > 0) {
flash('Username already taken.', 'err'); redirect('tab=users');
}
db()->prepare("UPDATE users SET username = ?, color = ? WHERE id = ?")->execute([$username, $color, $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.");
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)]);
db()->prepare("INSERT INTO users (username,password_hash,color,created_at) VALUES (?,?,?,?)")
->execute([$username, $hash, $color, 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 {
$stats['users'] = db()->query("SELECT COUNT(*) FROM users")->fetchColumn();
$stats['messages'] = db()->query("SELECT COUNT(*) FROM messages")->fetchColumn();
$stats['voice'] = db()->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn();
$stats['sessions'] = db()->query("SELECT COUNT(*) FROM sessions")->fetchColumn();
$stats['today'] = db()->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->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 = ROOT_DIR . '/' . 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")->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 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 = db()->query("SELECT DISTINCT day_key FROM messages 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'";
$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);
$voiceDays = array_unique(array_merge(
db()->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' 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.*,
(SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count,
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
FROM users u ORDER BY u.created_at DESC
")->fetchAll();
?>
// = count($allUsers) ?> USER(S)
NO USERS YET
ID Username Color Messages
Sessions Last Seen Joined Actions
#= $u['id'] ?>
= esc($u['username']) ?>
= esc($u['color']) ?>
= $u['msg_count'] ?>
= $u['sess_count'] ?>
= $u['last_seen'] ? ago((int)$u['last_seen']) : 'Never' ?>
= fmtTime((int)$u['created_at']) ?>
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.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