- Implemented.

Replaced polling with a single queued cursor worker, timeout recovery, 
backoff, deduplication, chronological insertion, and capped DOM history 
in cyberchat-v4.js.
Removed Groups UI, API, configuration, tier features, admin controls, 
and archive exposure.
Legacy group records remain stored but inaccessible.
Added configurable polling limits in config.json.
This commit is contained in:
Ty Clifford
2026-06-08 23:28:42 -04:00
parent ef7a9a330f
commit ccdbc47642
12 changed files with 368 additions and 667 deletions
+2 -10
View File
@@ -1,6 +1,6 @@
# CyberChat
CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with configurable paid tiers, Stripe subscriptions, Mailgun email verification, private groups, voice messages, personal archives, an administrator dashboard, and an optional MySQL/MariaDB mirror.
CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with configurable paid tiers, Stripe subscriptions, Mailgun email verification, voice messages, personal archives, an administrator dashboard, and an optional MySQL/MariaDB mirror.
## Included Features
@@ -10,12 +10,11 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio
- Stripe Checkout, Billing Portal, webhook synchronization, and cancellation
- Configurable plans, prices, Stripe Price IDs, limits, and feature assignments
- Subscription display switch that hides new sales while preserving billing management for existing customers
- Globally configurable private groups with plan-based member limits, including the owner
- Premium-configurable recording presence for both sending and viewing
- Premium-configurable automatic voice sending
- Tier-configurable automatic playback of newly received voice clips
- Administrator tier overrides that grant plan features without payment
- Resilient polling with stalled-request timeouts and post-send reconciliation
- Single-worker queued polling with cursor catch-up, request timeouts, deduplication, bounded retry backoff, and a capped browser message buffer
- Plan-based custom username colors
- Personal text archives retained for at least 30 days
- Plan-based voice archive access and JSON/CSV exports
@@ -64,8 +63,6 @@ The defaults are starting points and are fully editable in the dashboard.
| Feature | Free | Plus | Pro |
|---|---:|---:|---:|
| Groups available | Yes | Yes | Yes |
| Group members, including owner | 2 | 4 | 8 |
| Recording status send/view | No | Yes | Yes |
| Automatic voice send | No | Yes | Yes |
| Automatic voice play | No | Yes | Yes |
@@ -75,12 +72,8 @@ The defaults are starting points and are fully editable in the dashboard.
| Voice archive/export | No | Yes | Yes |
| Email 2FA | No | Yes | Yes |
Each tier has an explicit **Enable groups for this tier** toggle and a separate **Maximum People Per Group** value. The minimum is two people, including the group owner; disabling groups uses the toggle rather than a numeric value of zero.
Administrators can assign a tier directly from **Admin > Users > Edit > Tier Override**. The override grants that tier's features without changing Stripe billing data and remains in effect until it is changed back to **Follow Stripe / billing status**.
Private groups can be enabled or disabled globally under **Admin > Plans & Platform > Group Availability** or **Admin > Config > Feature Availability**. Each tier also has a **Groups Available** entitlement. Disabling either control hides group navigation and channels and blocks group API access without deleting existing groups or messages.
## Mailgun
Configure these fields under **Admin > Plans & Platform**:
@@ -168,7 +161,6 @@ api/account.php Email, color, and 2FA settings
api/archive.php Private personal archive and export
api/auth.php Registration, login, 2FA, sessions
api/billing.php Stripe checkout, portal, and cancellation
api/groups.php Private group management
api/messages.php Text, voice, polling, and recording presence
api/stripe-webhook.php Stripe event synchronization
assets/js/cyberchat-v4.js Browser application
+71 -54
View File
@@ -43,7 +43,6 @@ function ensureAdminMessageColumns(PDO $d): void {
$columns = [];
foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
foreach ([
'group_id' => 'INTEGER',
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT',
'voice_mime' => 'TEXT',
@@ -53,6 +52,11 @@ function ensureAdminMessageColumns(PDO $d): void {
}
}
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));
@@ -76,7 +80,6 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
$a->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
group_id INTEGER,
username TEXT NOT NULL,
color TEXT NOT NULL,
message TEXT NOT NULL,
@@ -152,7 +155,9 @@ function refreshArchiveMeta(PDO $archive): void {
key TEXT PRIMARY KEY,
value TEXT
)");
$count = (int)$archive->query("SELECT COUNT(*) FROM messages")->fetchColumn();
$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]);
@@ -180,22 +185,22 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrfCheck();
$act = $_POST['action'] ?? '';
if (in_array($act, ['save_group_availability','save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
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');
}
$returnTab = $act === 'save_group_availability' && ($_POST['return_tab'] ?? '') === 'config'
? 'config' : 'platform';
redirect('tab=' . $returnTab);
redirect('tab=platform');
}
// ── Messages ──────────────────────────────────────────────────────────────
if ($act === 'archive_voice_clip') {
$id = (int)($_POST['msg_id'] ?? 0);
$stmt = db()->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
$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) {
@@ -222,10 +227,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($existingFile === false) {
$insert = $adb->prepare("INSERT INTO messages
(id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
(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['group_id'] ?? null, $row['username'], $row['color'], $row['message'],
$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']
]);
@@ -236,7 +241,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($adb->inTransaction()) $adb->rollBack();
throw $e;
}
$delete = db()->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
$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.');
@@ -252,10 +258,12 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$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 = ?");
$live = db();
$public = adminPublicMessagePredicate($live);
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ? AND $public");
$row->execute([$id]);
deleteRecordingsForRows($row->fetchAll());
db()->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
$live->prepare("DELETE FROM messages WHERE id = ? AND $public")->execute([$id]);
flash("Message #$id deleted.");
} else {
[$_, $day] = explode(':', $src, 2);
@@ -286,7 +294,9 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
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]);
$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);
@@ -303,11 +313,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
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 = db()->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)");
$rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph) AND $public");
$rows->execute($ids);
deleteRecordingsForRows($rows->fetchAll());
db()->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
$live->prepare("DELETE FROM messages WHERE id IN ($ph) AND $public")->execute($ids);
flash(count($ids) . ' message(s) deleted.');
}
redirect($_POST['return_qs'] ?? '');
@@ -315,10 +327,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'delete_day_live') {
$day = $_POST['day_key'];
$rows = db()->prepare("SELECT voice_file FROM messages WHERE 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 = db()->prepare("DELETE FROM messages WHERE day_key = ?"); $st->execute([$day]);
$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');
}
@@ -340,8 +355,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($act === 'clear_all_live') {
deleteRecordingsForRows(db()->query("SELECT voice_file FROM messages")->fetchAll());
db()->exec("DELETE FROM messages");
$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');
}
@@ -456,11 +473,13 @@ $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();
$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'=>'?']; }
@@ -476,7 +495,10 @@ if ($isLoggedIn) {
$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){}
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]];
}
}
@@ -945,7 +967,9 @@ td.actions{white-space:nowrap;width:1px}
</div>
<div class="panel-body np">
<?php
$recent = db()->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();
$live = db();
$recent = $live->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)): ?>
<div class="empty">NO MESSAGES YET</div>
<?php else: ?>
@@ -1023,16 +1047,18 @@ td.actions{white-space:nowrap;width:1px}
$fDay = trim($_GET['fd'] ?? '');
$fType = trim($_GET['type'] ?? '');
$where = []; $params = [];
$live = db();
$where = [adminPublicMessagePredicate($live)]; $params = [];
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; }
if (in_array($fType, ['text','voice'], true)) { $where[] = "message_type = ?"; $params[] = $fType; }
$sql = "SELECT * FROM messages" . ($where ? " WHERE ".implode(" AND ", $where) : "") . " ORDER BY created_at DESC LIMIT 500";
$msgs = db()->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
$msgs = $live->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);
$days = $live->query("SELECT DISTINCT day_key FROM messages WHERE "
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
?>
<div class="search-bar">
@@ -1176,7 +1202,8 @@ td.actions{white-space:nowrap;width:1px}
$voiceRows = [];
if ($vSource !== 'archive') {
$vWhere = ["message_type = 'voice'"];
$live = db();
$vWhere = ["message_type = 'voice'", adminPublicMessagePredicate($live)];
$vParams = [];
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; }
@@ -1194,7 +1221,8 @@ td.actions{white-space:nowrap;width:1px}
try {
$vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']);
if (!$vAdb) continue;
$vSql = "SELECT * FROM messages WHERE message_type = 'voice'";
$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';
@@ -1212,8 +1240,10 @@ td.actions{white-space:nowrap;width:1px}
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(
db()->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
$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);
@@ -1326,7 +1356,7 @@ td.actions{white-space:nowrap;width:1px}
if ($adb) {
$fAUser = trim($_GET['au'] ?? '');
$fAText = trim($_GET['at'] ?? '');
$awhere = []; $aparams = [];
$awhere = [adminPublicMessagePredicate($adb)]; $aparams = [];
if ($fAUser) { $awhere[] = "username LIKE ?"; $aparams[] = '%'.$fAUser.'%'; }
if ($fAText) { $awhere[] = "message LIKE ?"; $aparams[] = '%'.$fAText.'%'; }
$asql = "SELECT * FROM messages" . ($awhere ? " WHERE ".implode(" AND ", $awhere) : "") . " ORDER BY created_at DESC LIMIT 500";
@@ -1506,9 +1536,11 @@ td.actions{white-space:nowrap;width:1px}
</div>
<?php
$allUsers = db()->query("
$live = db();
$allUsers = $live->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 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
@@ -1673,22 +1705,6 @@ td.actions{white-space:nowrap;width:1px}
<p>// edit config.json live — changes take effect immediately</p>
</div>
<div class="panel">
<div class="panel-head"><span class="panel-title">// FEATURE AVAILABILITY</span></div>
<div class="panel-body">
<form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="save_group_availability">
<input type="hidden" name="return_tab" value="config">
<label class="cb-row">
<input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>>
<span><strong>Groups Enabled</strong> — master switch for private group navigation, channels, and API access.</span>
</label>
<button type="submit" class="btn btn-g">SAVE GROUP AVAILABILITY</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">// config.json</span>
@@ -1720,12 +1736,13 @@ td.actions{white-space:nowrap;width:1px}
<tr><td class="mono">chat.max_username_length</td><td class="dim">integer</td><td>Max callsign length</td></tr>
<tr><td class="mono">chat.poll_interval_ms</td><td class="dim">integer</td><td>Client poll interval (ms)</td></tr>
<tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr>
<tr><td class="mono">chat.poll_max_backoff_ms</td><td class="dim">integer</td><td>Maximum delay after repeated poll failures</td></tr>
<tr><td class="mono">chat.max_rendered_messages</td><td class="dim">integer</td><td>Maximum chat rows retained in the browser</td></tr>
<tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr>
<tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr>
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
<tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr>
<tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr>
<tr><td class="mono">groups.enabled</td><td class="dim">bool</td><td>Global master switch for private groups</td></tr>
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr>
+4 -4
View File
@@ -14,7 +14,7 @@ if ($action === 'export') {
$format = strtolower((string)($_GET['format'] ?? 'json'));
$export = array_map(function(array $row) use ($canVoiceExport): array {
$item = [
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null,
'id' => (int)$row['id'],
'username' => $row['username'], 'message' => $row['message'],
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']),
];
@@ -28,9 +28,9 @@ if ($action === 'export') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'group_id', 'username', 'message', 'type', 'created_at', 'voice_url']);
fputcsv($out, ['id', 'username', 'message', 'type', 'created_at', 'voice_url']);
foreach ($export as $row) fputcsv($out, [
$row['id'], $row['group_id'], $row['username'], $row['message'], $row['type'],
$row['id'], $row['username'], $row['message'], $row['type'],
$row['created_at'], $row['voice_url'] ?? '',
]);
fclose($out);
@@ -44,7 +44,7 @@ if ($action === 'export') {
$payload = array_map(function(array $row): array {
return [
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null,
'id' => (int)$row['id'],
'message' => $row['message'], 'message_type' => $row['message_type'],
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null,
+2 -1
View File
@@ -10,6 +10,8 @@ jsonResponse([
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
'poll_max_backoff_ms' => $config['chat']['poll_max_backoff_ms'] ?? 15000,
'max_rendered_messages' => $config['chat']['max_rendered_messages'] ?? 500,
],
'ui' => $config['ui'] ?? [],
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
@@ -20,6 +22,5 @@ jsonResponse([
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
],
'groups' => ['enabled' => $config['groups']['enabled'] ?? true],
'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true],
]);
-125
View File
@@ -1,125 +0,0 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
$planGroupsEnabled = (bool)featureValue($user, 'groups_available', true);
if (!groupsAvailableForUser($user)) {
$error = groupsEnabled()
? 'Private groups are not available on your tier'
: 'Private groups are currently disabled';
if ($action === 'list') {
jsonResponse([
'groups' => [],
'member_limit' => 0,
'enabled' => false,
'platform_enabled' => groupsEnabled(),
'plan_enabled' => $planGroupsEnabled,
]);
}
jsonResponse([
'error' => $error,
'groups_enabled' => false,
'groups_platform_enabled' => groupsEnabled(),
], 403);
}
switch ($action) {
case 'list':
jsonResponse([
'groups' => userGroups((int)$user['id']),
'member_limit' => (int)featureValue($user, 'group_member_limit', 2),
'enabled' => true,
'platform_enabled' => true,
'plan_enabled' => true,
]);
case 'create':
$name = trim((string)($_POST['name'] ?? ''));
$names = array_values(array_unique(array_filter(array_map('trim', explode(',', (string)($_POST['members'] ?? ''))))));
if ($name === '' || strlen($name) > 40) jsonResponse(['error' => 'Group name must be 1-40 characters'], 400);
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
if (count($names) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
$memberIds = [];
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
foreach ($names as $username) {
if (strcasecmp($username, $user['username']) === 0) continue;
$lookup->execute([$username]);
$id = (int)$lookup->fetchColumn();
if (!$id) jsonResponse(['error' => "User '$username' was not found"], 404);
$memberIds[$id] = $id;
}
if (!$memberIds) jsonResponse(['error' => 'Add at least one other user to create a group'], 400);
if (count($memberIds) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
$db = getDB();
$db->beginTransaction();
$db->prepare("INSERT INTO chat_groups (owner_id,name,created_at,updated_at) VALUES (?,?,?,?)")
->execute([$user['id'], $name, time(), time()]);
$groupId = (int)$db->lastInsertId();
$insert = $db->prepare("INSERT INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,?,?)");
$insert->execute([$groupId, $user['id'], 'owner', time()]);
foreach ($memberIds as $id) $insert->execute([$groupId, $id, 'member', time()]);
$db->commit();
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'add_member':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$username = trim((string)($_POST['username'] ?? ''));
$groupStmt = getDB()->prepare("SELECT * FROM chat_groups WHERE id=? AND owner_id=?");
$groupStmt->execute([$groupId, $user['id']]);
$group = $groupStmt->fetch();
if (!$group) jsonResponse(['error' => 'Only the group owner can add members'], 403);
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
$countStmt = getDB()->prepare("SELECT COUNT(*) FROM group_members WHERE group_id=?");
$countStmt->execute([$groupId]);
if ((int)$countStmt->fetchColumn() >= $limit) jsonResponse(['error' => "This group has reached your $limit-member limit"], 403);
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
$lookup->execute([$username]);
$memberId = (int)$lookup->fetchColumn();
if (!$memberId) jsonResponse(['error' => 'User not found'], 404);
getDB()->prepare("INSERT OR IGNORE INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,'member',?)")
->execute([$groupId, $memberId, time()]);
getDB()->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([time(), $groupId]);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'remove_member':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$memberId = (int)($_POST['user_id'] ?? 0);
$stmt = getDB()->prepare("SELECT owner_id FROM chat_groups WHERE id=?");
$stmt->execute([$groupId]);
$ownerId = (int)$stmt->fetchColumn();
if (!$ownerId) jsonResponse(['error' => 'Group not found'], 404);
if ($ownerId !== (int)$user['id'] && $memberId !== (int)$user['id']) jsonResponse(['error' => 'Not allowed'], 403);
if ($memberId === $ownerId) jsonResponse(['error' => 'The owner must delete the group instead'], 400);
getDB()->prepare("DELETE FROM group_members WHERE group_id=? AND user_id=?")->execute([$groupId, $memberId]);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'delete':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$db = getDB();
$owner = $db->prepare("SELECT 1 FROM chat_groups WHERE id=? AND owner_id=?");
$owner->execute([$groupId, $user['id']]);
if (!$owner->fetchColumn()) jsonResponse(['error' => 'Only the group owner can delete this group'], 403);
$voice = $db->prepare("SELECT voice_file FROM messages WHERE group_id=? AND voice_file IS NOT NULL");
$voice->execute([$groupId]);
$voiceFiles = $voice->fetchAll();
$db->beginTransaction();
try {
$db->prepare("DELETE FROM recording_status WHERE group_key=?")->execute([groupKey($groupId)]);
$db->prepare("DELETE FROM messages WHERE group_id=?")->execute([$groupId]);
$db->prepare("DELETE FROM group_members WHERE group_id=?")->execute([$groupId]);
$db->prepare("DELETE FROM chat_groups WHERE id=?")->execute([$groupId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
foreach ($voiceFiles as $row) deleteVoiceFile($row['voice_file'] ?? null);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
default:
jsonResponse(['error' => 'Unknown action'], 400);
}
+84 -74
View File
@@ -6,19 +6,18 @@ sendCorsHeaders();
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
$action = $_POST['action'] ?? $_GET['action'] ?? '';
switch ($action) {
case 'send': sendTextMessage();
case 'send_voice': sendVoiceMessage();
case 'recording': setRecordingStatus();
case 'poll': pollMessages();
case 'history': archiveDays();
default: jsonResponse(['error' => 'Unknown action'], 400);
}
match ($action) {
'send' => sendTextMessage(),
'send_voice' => sendVoiceMessage(),
'recording' => setRecordingStatus(),
'poll' => pollMessages(),
'history' => archiveDays(),
default => jsonResponse(['error' => 'Unknown action'], 400),
};
function messagePayload(array $row): array {
return [
'id' => (int)$row['id'],
'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null,
'username' => $row['username'],
'color' => $row['color'],
'message' => $row['message'],
@@ -31,98 +30,105 @@ function messagePayload(array $row): array {
];
}
function requestedGroup(array $user): ?int {
$groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null);
if ($groupId !== null && !groupsAvailableForUser($user)) {
$error = groupsEnabled()
? 'Private groups are not available on your tier'
: 'Private groups are currently disabled';
jsonResponse([
'error' => $error,
'groups_enabled' => false,
'groups_platform_enabled' => groupsEnabled(),
], 403);
}
requireGroupAccess($user, $groupId);
return $groupId;
function publicMessageCondition(PDO $db): string {
return isset(tableColumns($db, 'messages')['group_id']) ? ' AND group_id IS NULL' : '';
}
function sendTextMessage(): never {
$user = authRequired();
$groupId = requestedGroup($user);
$message = trim((string)($_POST['message'] ?? ''));
$max = (int)getConfigVal('chat.max_message_length', 500);
if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
$created = time();
$db = getDB();
$db->prepare("INSERT INTO messages (user_id,group_id,username,color,message,created_at,day_key)
VALUES (?,?,?,?,?,?,?)")->execute([
$user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(),
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key)
VALUES (?,?,?,?,?,?)")->execute([
$user['id'], $user['username'], $user['color'], $message, $created, dayKey(),
]);
if ($groupId !== null) {
$db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]);
}
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
'color' => $user['color'], 'message' => $message, 'message_type' => 'text',
'created_at' => $created, 'day_key' => dayKey(),
'id' => $db->lastInsertId(),
'username' => $user['username'],
'color' => $user['color'],
'message' => $message,
'message_type' => 'text',
'created_at' => $created,
'day_key' => dayKey(),
])]);
}
function sendVoiceMessage(): never {
$user = authRequired();
$groupId = requestedGroup($user);
if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) {
jsonResponse(['error' => 'Your plan does not include voice messages'], 403);
}
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) jsonResponse(['error' => 'No voice clip uploaded'], 400);
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) {
jsonResponse(['error' => 'No voice clip uploaded'], 400);
}
$file = $_FILES['voice'];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) jsonResponse(['error' => 'Voice upload failed'], 400);
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
jsonResponse(['error' => 'Voice upload failed'], 400);
}
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) {
jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
}
$duration = (float)($_POST['duration'] ?? 0);
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
if ($duration <= 0 || $duration > $maxSeconds + 1) jsonResponse(['error' => "Voice clip must be $maxSeconds seconds or less"], 400);
if ($duration <= 0 || $duration > $maxSeconds + 1) {
jsonResponse(['error' => "Voice clip must be $maxSeconds seconds or less"], 400);
}
$mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : '';
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12);
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm';
elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav';
$allowed = ['audio/webm' => 'webm', 'video/webm' => 'webm', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/wave' => 'wav'];
$allowed = [
'audio/webm' => 'webm',
'video/webm' => 'webm',
'audio/wav' => 'wav',
'audio/x-wav' => 'wav',
'audio/wave' => 'wav',
];
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400);
$dir = voiceUploadDir();
ensureWritableDirectory($dir, 'Voice storage');
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) jsonResponse(['error' => 'Could not store voice clip'], 500);
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
jsonResponse(['error' => 'Could not store voice clip'], 500);
}
$created = time();
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
$db = getDB();
try {
$db->prepare("INSERT INTO messages
(user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([
$user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]',
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,'voice',?,?,?,?,?)")->execute([
$user['id'], $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, $created, dayKey(),
]);
if ($groupId !== null) {
$db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]);
}
} catch (Throwable $e) {
deleteVoiceFile($filename);
throw $e;
}
getDB()->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
->execute([groupKey($groupId), $user['id']]);
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
->execute([$user['id']]);
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
'color' => $user['color'], 'message' => '[Voice clip]', 'message_type' => 'voice',
'voice_file' => $filename, 'voice_mime' => $storedMime, 'voice_duration' => $duration,
'created_at' => $created, 'day_key' => dayKey(),
'id' => $db->lastInsertId(),
'username' => $user['username'],
'color' => $user['color'],
'message' => '[Voice clip]',
'message_type' => 'voice',
'voice_file' => $filename,
'voice_mime' => $storedMime,
'voice_duration' => $duration,
'created_at' => $created,
'day_key' => dayKey(),
])]);
}
@@ -131,60 +137,64 @@ function setRecordingStatus(): never {
if (!featureValue($user, 'recording_status', false)) {
jsonResponse(['error' => 'Your plan does not include recording indicators'], 403);
}
$groupId = requestedGroup($user);
$active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN);
$db = getDB();
if ($active) {
$db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?)
ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
->execute([groupKey($groupId), $user['id'], time() + 10]);
$db->prepare("INSERT INTO recording_status (channel_key,user_id,expires_at) VALUES ('public',?,?)
ON CONFLICT(channel_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
->execute([$user['id'], time() + 10]);
} else {
$db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
->execute([groupKey($groupId), $user['id']]);
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
->execute([$user['id']]);
}
jsonResponse(['success' => true]);
}
function pollMessages(): never {
$user = authRequired();
$groupId = requestedGroup($user);
$since = max(0, (int)($_GET['since'] ?? 0));
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
$db = getDB();
$whereGroup = $groupId === null ? 'group_id IS NULL' : 'group_id = ?';
$params = $groupId === null ? [dayKey()] : [dayKey(), $groupId];
$publicOnly = publicMessageCondition($db);
if ($since === 0) {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup ORDER BY id DESC LIMIT ?");
$params[] = $limit;
$stmt->execute($params);
$stmt = $db->prepare("SELECT * FROM messages
WHERE day_key=?$publicOnly ORDER BY id DESC LIMIT ?");
$stmt->execute([dayKey(), $limit]);
$rows = array_reverse($stmt->fetchAll());
$hasMore = false;
} else {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup AND id>? ORDER BY id LIMIT ?");
$params[] = $since;
$params[] = $limit;
$stmt->execute($params);
$stmt = $db->prepare("SELECT * FROM messages
WHERE day_key=?$publicOnly AND id>? ORDER BY id LIMIT ?");
$stmt->execute([dayKey(), $since, $limit + 1]);
$rows = $stmt->fetchAll();
$hasMore = count($rows) > $limit;
if ($hasMore) $rows = array_slice($rows, 0, $limit);
}
$cursor = $since;
foreach ($rows as $row) $cursor = max($cursor, (int)$row['id']);
$cutoff = time() - 300;
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?");
$onlineStmt->execute([$cutoff]);
$recording = [];
$db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]);
if (featureValue($user, 'recording_status', false)) {
$recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r
JOIN users u ON u.id=r.user_id WHERE r.group_key=? AND r.expires_at>? AND r.user_id!=?");
$recordStmt->execute([groupKey($groupId), time(), $user['id']]);
JOIN users u ON u.id=r.user_id
WHERE r.channel_key='public' AND r.expires_at>? AND r.user_id!=?");
$recordStmt->execute([time(), $user['id']]);
$recording = $recordStmt->fetchAll();
}
jsonResponse([
'messages' => array_map('messagePayload', $rows),
'cursor' => $cursor,
'has_more' => $hasMore,
'online' => (int)$onlineStmt->fetchColumn(),
'recording' => $recording,
'groups' => groupsAvailableForUser($user) ? userGroups((int)$user['id']) : [],
'groups_enabled' => groupsAvailableForUser($user),
'groups_platform_enabled' => groupsEnabled(),
'group_id' => $groupId,
'server_time' => time(),
]);
}
+159 -263
View File
@@ -4,11 +4,13 @@
const initialView = new URLSearchParams(location.search).get('view');
const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || '';
const state = {
config: {}, user: null, groups: [], billing: null,
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
groupId: null, lastId: 0, pollTimer: null, pollController: null,
polling: false, pollRequested: false, pollGeneration: 0, groupsAccess: null,
groupSeen: {}, groupUnread: {},
config: {}, user: null, billing: null,
view: ['chat', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
cursor: 0,
poll: {
generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null,
failures: 0, immediate: false, queue: [], queuedIds: new Set(), initial: true
},
loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '',
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
@@ -16,6 +18,7 @@
playQueue: [], queuePlaying: false, autoplayWarningShown: false, sending: false }
};
let root;
let wakeListenersBound = false;
const $ = (s, c) => (c || root).querySelector(s);
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
const esc = value => String(value == null ? '' : value)
@@ -51,30 +54,6 @@
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
? state.user.features[key] : fallback;
}
function groupsAvailable() {
return state.config.groups?.enabled !== false && state.groupsAccess !== false;
}
function applyGroupsAvailability(enabled, groups = [], platformEnabled = enabled) {
const next = enabled !== false;
const changed = groupsAvailable() !== next;
state.groupsAccess = next;
state.config.groups = { ...(state.config.groups || {}), enabled: platformEnabled !== false };
if (next) {
if (changed) syncGroups(groups);
} else {
state.groups = [];
state.groupSeen = {};
state.groupUnread = {};
state.groupId = null;
if (state.view === 'groups') state.view = 'chat';
}
return changed;
}
function disableGroups(message = '', platformEnabled = state.config.groups?.enabled !== false) {
applyGroupsAvailability(false, [], platformEnabled);
if (message) toast(message);
app();
}
function toast(message, type = 'error') {
const area = $('#cc-toasts');
if (!area) return;
@@ -159,10 +138,20 @@
if (!root) return;
root.id = 'cyberchat-root';
state.config = await api('config.php').catch(() => ({
chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 },
chat: {
max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000,
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500
},
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
}));
if (!wakeListenersBound) {
document.addEventListener('visibilitychange', () => {
if (!document.hidden) requestPoll();
});
window.addEventListener('online', requestPoll);
wakeListenersBound = true;
}
try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {}
shell();
api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {});
@@ -266,19 +255,7 @@
}
async function enter() {
const [groups, billing] = await Promise.all([
groupsAvailable()
? api('groups.php', null, 'action=list').catch(() => ({ groups: [] }))
: Promise.resolve({ groups: [], enabled: false, platform_enabled: false }),
api('billing.php', null, 'action=status').catch(() => null)
]);
state.groupsAccess = groups.enabled !== false;
state.config.groups = { ...(state.config.groups || {}), enabled: groups.platform_enabled !== false };
state.groups = groupsAvailable() ? (groups.groups || []) : [];
state.groupSeen = {};
state.groupUnread = {};
state.groups.forEach(group => { state.groupSeen[group.id] = Number(group.latest_message_id || 0); });
state.billing = billing;
state.billing = await api('billing.php', null, 'action=status').catch(() => null);
if (feature('auto_voice_play')) {
try {
const saved = localStorage.getItem('cc_auto_voice_play');
@@ -299,9 +276,8 @@
}
}
function app() {
if (!groupsAvailable() && state.view === 'groups') state.view = 'chat';
$('#cc-body').innerHTML = `<nav class="cc-app-nav">
<button data-view="chat">CHAT</button>${groupsAvailable() ? '<button data-view="groups">GROUPS</button>' : ''}
<button data-view="chat">CHAT</button>
<button data-view="archive">MY ARCHIVE</button><button data-view="account">ACCOUNT</button>
</nav><main class="cc-view" id="cc-view"></main>`;
$$('[data-view]').forEach(button => button.addEventListener('click', () => {
@@ -315,37 +291,16 @@
stopVoice(true);
$$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view));
if (state.view === 'chat') chat();
else if (state.view === 'groups' && groupsAvailable()) groupsView();
else if (state.view === 'archive') archiveView();
else accountView();
}
function groupOptions() {
return '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group =>
`<option value="${group.id}" ${state.groupId === group.id ? 'selected' : ''}>${state.groupUnread[group.id] ? '● ' : ''}${esc(group.name)} (${group.member_count})</option>`
).join('');
}
function syncGroups(groups, markActivity = false) {
if (!Array.isArray(groups)) return;
groups.forEach(group => {
const latest = Number(group.latest_message_id || 0);
const seen = Number(state.groupSeen[group.id] || 0);
if (markActivity && latest > seen && Number(group.id) !== Number(state.groupId)) {
state.groupUnread[group.id] = true;
}
if (!(group.id in state.groupSeen)) state.groupSeen[group.id] = latest;
});
state.groups = groups;
const select = $('#group-select');
if (select) select.innerHTML = groupOptions();
}
function chat() {
const max = state.config.chat?.max_message_length || 500;
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div>
${groupsAvailable() ? `<select class="cc-compact-select" id="group-select">${groupOptions()}</select>` : ''}</div>
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div></div>
<div class="cc-recording-indicator" id="recording" hidden></div>
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
@@ -356,16 +311,6 @@
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
$('#group-select')?.addEventListener('change', event => {
state.groupId = event.target.value ? Number(event.target.value) : null;
if (state.groupId !== null) {
state.groupUnread[state.groupId] = false;
const group = state.groups.find(item => Number(item.id) === state.groupId);
if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0);
}
state.lastId = 0;
chat();
});
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
$('#message').addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
@@ -380,7 +325,7 @@
try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {}
});
bindAudioPlayers($('#cc-view'));
state.lastId = 0;
state.cursor = 0;
startPoll();
}
async function sendText() {
@@ -388,150 +333,191 @@
const message = input.value.trim();
if (!message) return;
input.value = '';
const result = await api('messages.php', form({
action: 'send', message, group_id: state.groupId || '',
})).catch(() => ({ error: 'Message send failed. Polling will continue.' }));
if (result.groups_enabled === false) {
input.value = message;
return disableGroups(result.error, result.groups_platform_enabled !== false);
}
const result = await api('messages.php', form({ action: 'send', message }))
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
if (result.error) { input.value = message; return toast(result.error); }
appendMessage(result.message);
requestPoll();
}
function startPoll() {
stopPoll();
const generation = state.pollGeneration;
schedulePoll(generation, 0);
}
function schedulePoll(generation, delay = state.config.chat?.poll_interval_ms || 2000) {
if (generation !== state.pollGeneration || state.view !== 'chat') return;
if (state.pollTimer) clearTimeout(state.pollTimer);
state.pollTimer = setTimeout(async () => {
state.pollTimer = null;
await poll(generation);
if (generation !== state.pollGeneration || state.view !== 'chat') return;
const delay = state.pollRequested ? 0 : state.config.chat?.poll_interval_ms || 2000;
state.pollRequested = false;
schedulePoll(generation, delay);
}, Math.max(0, delay));
state.cursor = 0;
state.poll.active = true;
state.poll.failures = 0;
state.poll.immediate = true;
state.poll.initial = true;
state.poll.queue = [];
state.poll.queuedIds.clear();
void pollWorker(state.poll.generation);
}
function requestPoll() {
if (state.view !== 'chat') return;
if (state.polling) {
state.pollRequested = true;
return;
}
schedulePoll(state.pollGeneration, 0);
if (state.view !== 'chat' || !state.poll.active) return;
state.poll.immediate = true;
const wake = state.poll.wakeResolve;
if (wake) wake();
}
function stopPoll() {
if (state.pollTimer) clearTimeout(state.pollTimer);
state.pollController?.abort();
state.pollTimer = null;
state.pollController = null;
state.polling = false;
state.pollRequested = false;
state.pollGeneration++;
state.poll.active = false;
state.poll.generation++;
state.poll.controller?.abort();
state.poll.controller = null;
if (state.poll.wakeTimer) clearTimeout(state.poll.wakeTimer);
state.poll.wakeTimer = null;
const wake = state.poll.wakeResolve;
state.poll.wakeResolve = null;
if (wake) wake();
state.poll.immediate = false;
state.poll.queue = [];
state.poll.queuedIds.clear();
}
async function poll(generation = state.pollGeneration) {
if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return;
const requestedGroup = state.groupId;
const requestedSince = state.lastId;
async function pollWorker(generation) {
const interval = Math.max(250, Number(state.config.chat?.poll_interval_ms) || 2000);
const maxBackoff = Math.max(interval, Number(state.config.chat?.poll_max_backoff_ms) || 15000);
while (state.poll.active && state.poll.generation === generation && state.view === 'chat') {
state.poll.immediate = false;
const result = await pollOnce(generation);
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') break;
let delay = 0;
if (result.ok) {
state.poll.failures = 0;
if (!result.hasMore && !state.poll.immediate) delay = interval;
} else {
state.poll.failures++;
if (!state.poll.immediate) {
delay = Math.min(maxBackoff, 500 * (2 ** Math.min(state.poll.failures - 1, 6)));
}
}
if (delay > 0) await waitForPoll(delay, generation);
}
}
function waitForPoll(delay, generation) {
if (!state.poll.active || state.poll.generation !== generation) return Promise.resolve();
return new Promise(resolve => {
let settled = false;
let timer = null;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (state.poll.wakeTimer === timer) state.poll.wakeTimer = null;
if (state.poll.wakeResolve === finish) state.poll.wakeResolve = null;
resolve();
};
timer = setTimeout(finish, Math.max(0, delay));
state.poll.wakeTimer = timer;
state.poll.wakeResolve = finish;
if (state.poll.immediate) finish();
});
}
async function pollOnce(generation) {
const requestedSince = state.cursor;
const controller = new AbortController();
const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
state.pollController = controller;
state.polling = true;
state.poll.controller = controller;
try {
const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' });
const result = await api('messages.php', { signal: controller.signal }, query.toString()).catch(() => null);
if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return;
if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) {
const availabilityChanged = applyGroupsAvailability(
result.groups_enabled !== false,
result.groups || [],
result.groups_platform_enabled !== false
);
if (availabilityChanged) {
toast(groupsAvailable() ? 'Private groups are now available' : 'Private groups have been disabled', 'success');
app();
return;
const query = new URLSearchParams({ action: 'poll', since: requestedSince });
const result = await api('messages.php', { signal: controller.signal }, query.toString());
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') {
return { ok: false, hasMore: false };
}
if (!result || result.error || !Array.isArray(result.messages)) {
throw new Error(result?.error || 'Invalid poll response');
}
$('#cc-conn-dot').className = 'cc-conn-dot online';
$('#cc-online-count').textContent = result.online;
if (groupsAvailable()) syncGroups(result.groups, true);
if (requestedSince === 0) renderMessages(result.messages || []);
else (result.messages || []).forEach(message => appendMessage(message, true));
if (result.messages?.length) {
state.lastId = Math.max(state.lastId, ...result.messages.map(message => Number(message.id) || 0));
}
if (requestedGroup !== null) {
state.groupUnread[requestedGroup] = false;
state.groupSeen[requestedGroup] = Math.max(Number(state.groupSeen[requestedGroup] || 0), Number(state.lastId || 0));
}
const initial = state.poll.initial;
enqueuePollMessages(result.messages, !initial);
state.poll.initial = false;
const messageCursor = result.messages.reduce(
(cursor, message) => Math.max(cursor, Number(message.id) || 0),
requestedSince
);
const nextCursor = Math.max(requestedSince, Number(result.cursor) || 0, messageCursor);
state.cursor = Math.max(state.cursor, nextCursor);
const recording = $('#recording');
if (recording) {
recording.hidden = !result.recording?.length;
recording.textContent = result.recording?.length
? result.recording.map(user => user.username).join(', ')
+ (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
: '';
} else {
$('#cc-conn-dot').className = 'cc-conn-dot offline';
if (result?.error && requestedGroup !== null) await recoverGroupAccess(requestedGroup, generation);
}
return { ok: true, hasMore: Boolean(result.has_more) && nextCursor > requestedSince };
} catch (error) {
if (state.poll.active && state.poll.generation === generation) {
$('#cc-conn-dot').className = 'cc-conn-dot offline';
}
return { ok: false, hasMore: false };
} finally {
clearTimeout(timeout);
if (state.pollController === controller) state.pollController = null;
if (generation === state.pollGeneration) state.polling = false;
if (state.poll.controller === controller) state.poll.controller = null;
}
}
async function recoverGroupAccess(groupId, generation) {
const result = await api('groups.php', null, 'action=list').catch(() => null);
if (!result || result.error || generation !== state.pollGeneration) return;
if (result.enabled === false) {
disableGroups(
result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
result.platform_enabled !== false
);
return;
}
syncGroups(result.groups || []);
if (!state.groups.some(group => Number(group.id) === Number(groupId))) {
state.groupId = null;
state.lastId = 0;
toast('Your group access changed; returned to the public lobby');
chat();
}
}
function renderMessages(messages) {
function enqueuePollMessages(messages, incoming) {
const list = $('#messages');
list.innerHTML = messages.length ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
messages.forEach(appendMessage);
if (messages.length) {
state.lastId = Math.max(state.lastId, ...messages.map(message => Number(message.id) || 0));
if (!list) return;
const sorted = [...messages].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
sorted.forEach(message => {
const numericId = Number(message.id);
if (!Number.isInteger(numericId) || numericId < 1) return;
const id = String(numericId);
if (state.poll.queuedIds.has(id) || list.querySelector(`[data-id="${id}"]`)) return;
state.poll.queuedIds.add(id);
state.poll.queue.push({ message, incoming });
});
drainPollQueue();
if (!list.querySelector('.cc-msg') && state.poll.queue.length === 0) {
list.innerHTML = '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
}
list.scrollTop = list.scrollHeight;
}
function drainPollQueue() {
while (state.poll.queue.length) {
const entry = state.poll.queue.shift();
const id = String(entry.message?.id ?? '');
try {
appendMessage(entry.message, entry.incoming);
} catch (error) {
console.error('Could not render chat message', error);
} finally {
state.poll.queuedIds.delete(id);
}
}
}
function appendMessage(message, incoming = false) {
const list = $('#messages');
if (!list || list.querySelector(`[data-id="${message.id}"]`)) return;
const id = Number(message?.id);
if (!list || !Number.isInteger(id) || id < 1 || list.querySelector(`[data-id="${id}"]`)) return;
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
const item = document.createElement('div');
item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new';
item.dataset.id = message.id;
item.dataset.id = String(id);
const content = message.message_type === 'voice' && message.voice_url
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
: linkify(esc(message.message));
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
<span class="cc-msg-sep">&gt;</span><span class="cc-msg-text">${content}</span>`;
list.appendChild(item);
const nextMessage = Array.from(list.querySelectorAll('.cc-msg'))
.find(existing => Number(existing.dataset.id) > id);
if (nextMessage) list.insertBefore(item, nextMessage);
else list.appendChild(item);
trimMessageBuffer(list);
bindAudioPlayers(item);
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
enqueueVoiceAutoplay(item);
}
list.scrollTop = list.scrollHeight;
}
function trimMessageBuffer(list) {
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
const messages = Array.from(list.querySelectorAll('.cc-msg'));
messages.slice(0, Math.max(0, messages.length - limit)).forEach(message => {
$('audio', message)?.pause();
message.remove();
});
}
function enqueueVoiceAutoplay(item) {
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
const audio = $('audio', item);
@@ -570,7 +556,7 @@
async function recording(active) {
if (!feature('recording_status')) return;
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0', group_id: state.groupId || '' })).catch(() => {});
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0' })).catch(() => {});
}
async function toggleVoice() {
if (state.voice.recorder) return stopVoice(false);
@@ -644,102 +630,17 @@
state.voice.sending = true;
const body = new FormData();
body.append('action', 'send_voice');
body.append('group_id', state.groupId || '');
body.append('duration', state.voice.duration.toFixed(2));
body.append('voice', state.voice.blob, 'voice.webm');
const result = await api('messages.php', { method: 'POST', body })
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
state.voice.sending = false;
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
appendMessage(result.message);
discardVoice();
requestPoll();
}
function groupsView() {
if (!groupsAvailable()) {
state.view = 'chat';
renderView();
return;
}
const limit = Number(feature('group_member_limit', 2));
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>GROUPS</h2>
<p>Up to ${limit} people per group, including you.</p></div></div>
<section class="cc-card"><h3>CREATE GROUP</h3><div class="cc-form-grid">
<input class="cc-input" id="group-name" maxlength="40" placeholder="Group name">
<input class="cc-input" id="group-members" placeholder="Member callsigns, comma separated">
<button class="cc-inline-btn primary" id="group-create">CREATE</button></div></section>
<div class="cc-card-grid">${state.groups.length ? state.groups.map(groupCard).join('') : '<div class="cc-empty-card">No private groups yet.</div>'}</div></div>`;
$('#group-create').addEventListener('click', createGroup);
$$('[data-open]').forEach(button => button.addEventListener('click', () => {
state.groupId = Number(button.dataset.open);
state.groupUnread[state.groupId] = false;
const group = state.groups.find(item => Number(item.id) === state.groupId);
if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0);
state.view = 'chat';
renderView();
}));
$$('[data-delete]').forEach(button => button.addEventListener('click', () => deleteGroup(Number(button.dataset.delete))));
$$('[data-add]').forEach(button => button.addEventListener('click', () => addMember(Number(button.dataset.add))));
$$('[data-remove]').forEach(button => button.addEventListener('click', () => {
const [groupId, userId] = button.dataset.remove.split(':').map(Number);
removeMember(groupId, userId);
}));
api('groups.php', null, 'action=list').then(result => {
if (result.error || state.view !== 'groups') return;
if (result.enabled === false) {
disableGroups(
result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
result.platform_enabled !== false
);
return;
}
const before = JSON.stringify(state.groups);
syncGroups(result.groups || []);
if (JSON.stringify(state.groups) !== before) groupsView();
}).catch(() => {});
}
function groupCard(group) {
const owner = group.owner_id === Number(state.user.id);
return `<section class="cc-card"><div class="cc-card-title"><h3>${esc(group.name)}</h3>
<button class="cc-inline-btn" data-open="${group.id}">OPEN CHAT</button></div>
<div class="cc-member-list">${group.members.map(member => `<span style="color:${esc(member.color)}">${esc(member.username)}
${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` <button data-remove="${group.id}:${member.id}">x</button>` : ''}</span>`).join('')}</div>
${owner ? `<div class="cc-form-grid compact"><input class="cc-input" id="add-${group.id}" placeholder="Callsign">
<button class="cc-inline-btn" data-add="${group.id}">ADD</button><button class="cc-inline-btn danger" data-delete="${group.id}">DELETE</button></div>` : ''}</section>`;
}
async function createGroup() {
const result = await api('groups.php', form({ action: 'create', name: $('#group-name').value.trim(), members: $('#group-members').value.trim() }));
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
groupsView();
}
async function addMember(groupId) {
const result = await api('groups.php', form({ action: 'add_member', group_id: groupId, username: $('#add-' + groupId).value.trim() }));
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
groupsView();
}
async function removeMember(groupId, userId) {
const result = await api('groups.php', form({ action: 'remove_member', group_id: groupId, user_id: userId }));
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
groupsView();
}
async function deleteGroup(groupId) {
if (!confirm('Delete this group and its messages?')) return;
const result = await api('groups.php', form({ action: 'delete', group_id: groupId }));
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
state.groups = result.groups;
if (state.groupId === groupId) state.groupId = null;
groupsView();
}
async function archiveView(search = '') {
$('#cc-view').innerHTML = '<div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING YOUR ARCHIVE</span></div>';
const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString());
@@ -853,12 +754,7 @@
stopVoice(true);
if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {});
state.user = null;
state.groups = [];
state.billing = null;
state.groupId = null;
state.groupsAccess = null;
state.groupSeen = {};
state.groupUnread = {};
state.voice.playQueue = [];
state.voice.queuePlaying = false;
auth('login');
+32 -90
View File
@@ -69,14 +69,6 @@ function getConfigVal(string $path, mixed $default = null): mixed {
return $value;
}
function groupsEnabled(): bool {
return (bool)getConfigVal('groups.enabled', true);
}
function groupsAvailableForUser(array $user): bool {
return groupsEnabled() && (bool)featureValue($user, 'groups_available', true);
}
function setConfigValues(array $changes): void {
$config = getConfig();
foreach ($changes as $path => $value) {
@@ -214,28 +206,9 @@ function initDB(PDO $db): void {
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)");
$db->exec("CREATE TABLE IF NOT EXISTS chat_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
)");
$db->exec("CREATE TABLE IF NOT EXISTS group_members (
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at INTEGER NOT NULL,
PRIMARY KEY(group_id, user_id),
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)");
$db->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
group_id INTEGER,
username TEXT NOT NULL,
color TEXT NOT NULL,
message TEXT NOT NULL,
@@ -245,11 +218,9 @@ function initDB(PDO $db): void {
voice_duration REAL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
day_key TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE
FOREIGN KEY(user_id) REFERENCES users(id)
)");
ensureColumns($db, 'messages', [
'group_id' => 'INTEGER',
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT',
'voice_mime' => 'TEXT',
@@ -315,11 +286,15 @@ function initDB(PDO $db): void {
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)");
$recordingColumns = tableColumns($db, 'recording_status');
if ($recordingColumns && !isset($recordingColumns['channel_key'])) {
$db->exec("DROP TABLE recording_status");
}
$db->exec("CREATE TABLE IF NOT EXISTS recording_status (
group_key TEXT NOT NULL,
channel_key TEXT NOT NULL,
user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY(group_key, user_id),
PRIMARY KEY(channel_key, user_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)");
$db->exec("CREATE TABLE IF NOT EXISTS visitor_events (
@@ -339,7 +314,6 @@ function initDB(PDO $db): void {
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_group ON messages(group_id, id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)");
@@ -357,28 +331,25 @@ function seedPlans(PDO $db): void {
'price' => 0, 'sort' => 0,
'features' => [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
'auto_voice_play' => false, 'groups_available' => true,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
],
],
'plus' => [
'name' => 'Plus', 'description' => 'Premium voice, identity, security, and groups for four.',
'name' => 'Plus', 'description' => 'Premium voice, identity, security, and 90-day personal archives.',
'price' => 499, 'sort' => 10,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
'auto_voice_play' => true, 'groups_available' => true,
'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90,
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 90,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
],
],
'pro' => [
'name' => 'Pro', 'description' => 'Expanded groups and one year of personal archives.',
'name' => 'Pro', 'description' => 'Premium voice, security, and one year of personal archives.',
'price' => 999, 'sort' => 20,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
'auto_voice_play' => true, 'groups_available' => true,
'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365,
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 365,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
],
],
@@ -397,8 +368,11 @@ function seedPlans(PDO $db): void {
$insertFeature->execute([$planId, $key, json_encode($value)]);
}
}
$db->exec("INSERT OR IGNORE INTO plan_features (plan_id,feature_key,value_json)
SELECT id,'groups_available','true' FROM plans");
$db->exec("DELETE FROM plan_features WHERE feature_key IN ('groups_available','group_member_limit')");
$db->exec("UPDATE plans SET description='Premium voice, identity, security, and 90-day personal archives.'
WHERE slug='plus' AND description='Premium voice, identity, security, and groups for four.'");
$db->exec("UPDATE plans SET description='Premium voice, security, and one year of personal archives.'
WHERE slug='pro' AND description='Expanded groups and one year of personal archives.'");
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn();
$db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
}
@@ -532,43 +506,9 @@ function dayKey(): string {
return date('Y-m-d');
}
function normalizeGroupId(mixed $value): ?int {
$id = (int)$value;
return $id > 0 ? $id : null;
}
function groupKey(?int $groupId): string {
return $groupId ? 'group:' . $groupId : 'lobby';
}
function requireGroupAccess(array $user, ?int $groupId): void {
if ($groupId === null) return;
$stmt = getDB()->prepare("SELECT 1 FROM group_members WHERE group_id=? AND user_id=?");
$stmt->execute([$groupId, $user['id']]);
if (!$stmt->fetchColumn()) jsonResponse(['error' => 'You are not a member of this group'], 403);
}
function userGroups(int $userId): array {
$stmt = getDB()->prepare("SELECT g.id,g.name,g.owner_id,g.created_at,gm.role,
(SELECT COUNT(*) FROM group_members x WHERE x.group_id=g.id) AS member_count,
(SELECT MAX(m.id) FROM messages m WHERE m.group_id=g.id) AS latest_message_id,
(SELECT MAX(m.created_at) FROM messages m WHERE m.group_id=g.id) AS latest_message_at
FROM chat_groups g JOIN group_members gm ON gm.group_id=g.id
WHERE gm.user_id=? ORDER BY g.updated_at DESC,g.id DESC");
$stmt->execute([$userId]);
$groups = $stmt->fetchAll();
$members = getDB()->prepare("SELECT u.id,u.username,u.color,gm.role FROM group_members gm
JOIN users u ON u.id=gm.user_id WHERE gm.group_id=? ORDER BY gm.role='owner' DESC,u.username");
foreach ($groups as &$group) {
$group['id'] = (int)$group['id'];
$group['owner_id'] = (int)$group['owner_id'];
$group['member_count'] = (int)$group['member_count'];
$group['latest_message_id'] = (int)($group['latest_message_id'] ?? 0);
$group['latest_message_at'] = (int)($group['latest_message_at'] ?? 0);
$members->execute([$group['id']]);
$group['members'] = $members->fetchAll();
}
return $groups;
function publicMessagesOnly(PDO $db, string $alias = ''): string {
$prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
return isset(tableColumns($db, 'messages')['group_id']) ? " AND {$prefix}group_id IS NULL" : '';
}
function voiceUploadDir(): string {
@@ -595,13 +535,13 @@ function getArchiveDB(string $year, string $month, string $day): PDO {
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA journal_mode=WAL');
$db->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, group_id INTEGER,
id INTEGER PRIMARY KEY, 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
)");
ensureColumns($db, 'messages', [
'group_id' => 'INTEGER', 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
]);
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)");
@@ -627,7 +567,8 @@ function archiveYesterdayIfNeeded(): void {
$flag = ROOT_DIR . '/db/.archived_' . $yesterday;
if (file_exists($flag)) return;
$db = getDB();
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id");
$publicOnly = publicMessagesOnly($db);
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=?$publicOnly ORDER BY id");
$stmt->execute([$yesterday]);
$rows = $stmt->fetchAll();
if ($rows) {
@@ -635,11 +576,11 @@ function archiveYesterdayIfNeeded(): void {
$archive = getArchiveDB($year, $month, $day);
$archive->beginTransaction();
$insert = $archive->prepare("INSERT OR IGNORE INTO messages
(id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
foreach ($rows as $row) {
$insert->execute([
$row['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'],
$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'],
]);
@@ -649,7 +590,7 @@ function archiveYesterdayIfNeeded(): void {
$meta->execute(['message_count', (string)count($rows)]);
$archive->commit();
}
$db->prepare("DELETE FROM messages WHERE day_key=?")->execute([$yesterday]);
$db->prepare("DELETE FROM messages WHERE day_key=?$publicOnly")->execute([$yesterday]);
file_put_contents($flag, date('c'), LOCK_EX);
}
@@ -658,18 +599,19 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
$cutoff = time() - ($days * 86400);
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
$rows = [];
$sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
$db = getDB();
$sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($db);
$params = [$user['id'], $cutoff];
if (!$includeVoice) $sql .= " AND message_type='text'";
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; }
$stmt = getDB()->prepare($sql . " ORDER BY created_at DESC LIMIT ?");
$stmt = $db->prepare($sql . " ORDER BY created_at DESC LIMIT ?");
$params[] = $limit;
$stmt->execute($params);
$rows = $stmt->fetchAll();
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
$archive = getArchiveDB($year, $month, $day);
$archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
$archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($archive);
$archiveParams = [$user['id'], $cutoff];
if (!$includeVoice) $archiveSql .= " AND message_type='text'";
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
+2 -3
View File
@@ -7,7 +7,9 @@
"max_username_length": 12,
"poll_interval_ms": 2000,
"poll_timeout_ms": 12000,
"poll_max_backoff_ms": 15000,
"messages_per_page": 100,
"max_rendered_messages": 500,
"session_lock_ip": true,
"session_lock_cookie": true,
"allow_guest": false
@@ -24,9 +26,6 @@
"auto_play_default": true,
"upload_dir": "uploads/voice"
},
"groups": {
"enabled": true
},
"database": {
"main_db": "db/chat.sqlite",
"archive_path": "archive/{year}/{month}/{day}.sqlite",
+4 -4
View File
@@ -6,7 +6,7 @@
<title>CyberChat — Embed Example</title>
<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 rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.6">
<style>
html, body {
height: auto;
@@ -88,13 +88,13 @@
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.5"</span><span class="kw">&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.6"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.5"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.6"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here');
+2 -2
View File
@@ -7,14 +7,14 @@
<title>Chat @ TyClifford.com</title>
<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 rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.6">
</head>
<body>
<div id="app"></div>
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app');
+2 -33
View File
@@ -1,15 +1,9 @@
<?php
function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_group_availability') {
setConfigValues(['groups.enabled' => !empty($_POST['groups_enabled'])]);
return 'Group availability saved.';
}
if ($action === 'save_platform_services') {
setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'groups.enabled' => !empty($_POST['groups_enabled']),
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
@@ -33,7 +27,7 @@ function handleAdminPlatformAction(string $action): ?string {
$db = getDB();
$plans = $_POST['plans'] ?? [];
$boolFeatures = [
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'groups_available', 'username_color',
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color',
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
];
$db->beginTransaction();
@@ -62,7 +56,6 @@ function handleAdminPlatformAction(string $action): ?string {
foreach ($boolFeatures as $key) {
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
}
$upsertFeature->execute([$id, 'group_member_limit', json_encode(max(2, min(100, (int)($plan['features']['group_member_limit'] ?? 2))))]);
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
}
$db->commit();
@@ -87,8 +80,7 @@ function handleAdminPlatformAction(string $action): ?string {
$id = (int)$db->lastInsertId();
$features = [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
'auto_voice_play' => false, 'groups_available' => false,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
];
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
@@ -120,7 +112,6 @@ function renderAdminPlatformTab(): void {
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
'groups' => (int)$db->query("SELECT COUNT(*) FROM chat_groups")->fetchColumn(),
];
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
@@ -136,7 +127,6 @@ function renderAdminPlatformTab(): void {
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
<div class="stat-card orange"><div class="stat-num"><?= $stats['groups'] ?></div><div class="stat-label">Private Groups</div></div>
</div>
<form method="post">
@@ -146,10 +136,6 @@ function renderAdminPlatformTab(): void {
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// GROUP AVAILABILITY</span></div>
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>>
<span>Enable private groups. When off, group navigation and channels are hidden while existing group data is preserved.</span></label></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
<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>
@@ -204,23 +190,6 @@ function renderAdminPlatformTab(): void {
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
</div>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
<div style="border:1px solid var(--bd2);border-radius:4px;padding:12px;margin:12px 0">
<div class="panel-title" style="margin-bottom:10px">// GROUPS</div>
<div class="form-row">
<label class="cb-row" style="align-self:center;margin:0">
<input type="checkbox" name="plans[<?= $plan['id'] ?>][features][groups_available]"
<?= !empty($plan['features']['groups_available']) ? 'checked' : '' ?>>
<span><strong>Enable groups for this tier</strong></span>
</label>
<div class="field">
<label>Maximum People Per Group</label>
<input type="number" min="2" max="100" step="1"
name="plans[<?= $plan['id'] ?>][features][group_member_limit]"
value="<?= max(2, (int)($plan['features']['group_member_limit'] ?? 2)) ?>">
<div class="dim" style="margin-top:5px">Minimum 2, including the group owner. Use the toggle to disable groups; no 0 value is needed.</div>
</div>
</div>
</div>
<div class="form-row3">
<?php foreach ($featureKeys as $key): ?>
<?php if ($key === 'text_archive_days'): ?>