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 = 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); removeLegacyGroupSchema($a, false); $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); 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); removeLegacyGroupSchema($a, false); $a->exec("CREATE TABLE IF NOT EXISTS archive_meta ( key TEXT PRIMARY KEY, value TEXT )"); $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); 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")->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'"); $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'"); $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(); $row = $live->prepare("SELECT voice_file FROM messages WHERE id = ?"); $row->execute([$id]); deleteRecordingsForRows($row->fetchAll()); $live->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') { $live = db(); $live->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) { $live = db(); $ph = implode(',', array_fill(0, count($ids), '?')); $rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)"); $rows->execute($ids); deleteRecordingsForRows($rows->fetchAll()); $live->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']; $live = db(); $rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ?"); $rows->execute([$day]); deleteRecordingsForRows($rows->fetchAll()); $st = $live->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 = 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(); deleteRecordingsForRows($live->query("SELECT voice_file FROM messages")->fetchAll()); $live->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']); $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(); $stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn(); $stats['messages'] = $live->query("SELECT COUNT(*) FROM messages")->fetchColumn(); $stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->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') . "'")->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 = archiveDB($m[1], $m[2], $m[3]); if ($x) $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
//

DASHBOARD

// system overview

Users
Live Messages
Live Voice Clips
Today
Sessions
Active Now
// RECENT MESSAGES VIEW ALL
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
UserMessageTime
// RECENT USERS VIEW ALL
query("SELECT * FROM users ORDER BY created_at DESC LIMIT 8")->fetchAll(); if (empty($recUsers)): ?>
NO USERS YET
UsernameColorJoined

LIVE MESSAGES

// today's active chat —

prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll(); // Days available $days = $live->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN); ?>
// MESSAGE(S)
0 selected
NO MESSAGES FOUND
IDDayUserMessageTimeActions
#
VOICE · s
// DANGER ZONE

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); $live = db(); $voiceDays = array_unique(array_merge( $live->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]); ?>
// VOICE CLIP(S) MAX 500 RESULTS
NO VOICE CLIPS FOUND
IDSourceDayUserRecordingFileTimeActions
# RECORDING MISSING · s · KB

ARCHIVES

// daily chat logs — archive(s)

prepare($asql); $astmt->execute($aparams); $archMsgs = $astmt->fetchAll(); } } ?>
◂ BACK TO LIST

// archive day view

// MESSAGE(S)
NO MESSAGES FOUND
IDUserMessageTimeActions
#
VOICE · s
// DANGER ZONE
// ARCHIVED DAYS
NO ARCHIVES YET
DateMessagesFile SizeActions
msgs KB
BROWSE

USERS

// manage accounts and credentials

// CREATE USER
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) 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(); ?>
// USER(S)
NO USERS YET
IDUsernameTierColorMessages SessionsLast SeenJoinedActions
#

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(); ?>
// SESSION(S)
NO SESSIONS
$timeout; ?>
TokenUserIPStatus Last ActiveCreatedActions

CONFIGURATION

// edit config.json live — changes take effect immediately

// config.json
// QUICK REFERENCE
KeyTypeDescription
chat.max_message_lengthintegerMax chars per message
chat.max_username_lengthintegerMax callsign length
chat.poll_interval_msintegerClient poll interval (ms)
chat.poll_timeout_msintegerAbort and retry a stalled poll after this many milliseconds
chat.poll_max_backoff_msintegerMaximum delay after repeated poll failures
chat.max_rendered_messagesintegerMaximum chat rows retained in the browser
chat.session_lock_ipboolLock session to IP
voice.enabledboolShow voice recording controls
voice.max_duration_secondsintegerMaximum voice clip duration
voice.max_upload_bytesintegerMaximum recording upload size
voice.auto_play_defaultboolQueue and play incoming clips sequentially by default
voice.transcoding.enabledboolUse FFmpeg for uploaded voice clips
voice.transcoding.ffmpeg_pathstringFFmpeg executable name or absolute path
voice.transcoding.profilestringm4a_aac or mp4_h264_aac
voice.transcoding.audio_bitrate_kbpsintegerAAC output bitrate from 48 to 320 kbps
voice.transcoding.timeout_secondsintegerFFmpeg upload conversion timeout
voice.transcoding.fallback_to_sourceboolRetain WebM/MP4 source when conversion fails
security.min_password_lengthintegerMin password chars
security.session_timeout_hoursintegerSession expiry in hours
security.bcrypt_costintegerbcrypt work factor (10–14)
ui.app_titlestringHeader title text
colors.user_palettearrayHex colors for new users