From ccdbc476429dfccaad55a0616daaa6716adc7228 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 8 Jun 2026 23:28:42 -0400 Subject: [PATCH] - 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. --- README.md | 12 +- admin.php | 125 ++++++----- api/archive.php | 8 +- api/config.php | 3 +- api/groups.php | 125 ----------- api/messages.php | 158 +++++++------- assets/js/cyberchat-v4.js | 430 +++++++++++++++----------------------- bootstrap.php | 122 +++-------- config.json | 5 +- embed-example.html | 8 +- index.html | 4 +- lib/admin_platform.php | 35 +--- 12 files changed, 368 insertions(+), 667 deletions(-) delete mode 100644 api/groups.php diff --git a/README.md b/README.md index c7dbc12..5a640b8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/admin.php b/admin.php index 99347a9..a3fc22a 100644 --- a/admin.php +++ b/admin.php @@ -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}
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)): ?>
NO MESSAGES YET
@@ -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); ?> 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}

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

-
-
// FEATURE AVAILABILITY
-
-
- - - - - -
-
-
-
// config.json @@ -1720,12 +1736,13 @@ td.actions{white-space:nowrap;width:1px} 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 - groups.enabledboolGlobal master switch for private groups security.min_password_lengthintegerMin password chars security.session_timeout_hoursintegerSession expiry in hours security.bcrypt_costintegerbcrypt work factor (10–14) diff --git a/api/archive.php b/api/archive.php index 09bec4d..66d71d2 100644 --- a/api/archive.php +++ b/api/archive.php @@ -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, diff --git a/api/config.php b/api/config.php index 55c8191..8598994 100644 --- a/api/config.php +++ b/api/config.php @@ -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], ]); diff --git a/api/groups.php b/api/groups.php deleted file mode 100644 index 81032c9..0000000 --- a/api/groups.php +++ /dev/null @@ -1,125 +0,0 @@ - [], - '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); -} diff --git a/api/messages.php b/api/messages.php index 00d046c..e6212a1 100644 --- a/api/messages.php +++ b/api/messages.php @@ -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(), ]); } diff --git a/assets/js/cyberchat-v4.js b/assets/js/cyberchat-v4.js index 68db9ed..0a5b796 100644 --- a/assets/js/cyberchat-v4.js +++ b/assets/js/cyberchat-v4.js @@ -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 = `
`; $$('[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 '' + state.groups.map(group => - `` - ).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 = `
YOU:${esc(state.user.username)} - ${esc(state.user.plan.name)}
- ${groupsAvailable() ? `` : ''}
+ ${esc(state.user.plan.name)}
LOADING CHANNEL
${voice ? `
@@ -356,16 +311,6 @@
` : ''}
${max}
`; - $('#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; - } - $('#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 recording = $('#recording'); + 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; + 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 ? '' : '
NO MESSAGES IN THIS CHANNEL TODAY
'; - 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 = '
NO MESSAGES IN THIS CHANNEL TODAY
'; } 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 ? `VOICE ${duration(message.voice_duration)}${audioPlayer(publicUrl(message.voice_url))}` : linkify(esc(message.message)); item.innerHTML = `${clock(message.created_at)} ${esc(message.username)} >${content}`; - 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 = `

GROUPS

-

Up to ${limit} people per group, including you.

-

CREATE GROUP

- - -
-
${state.groups.length ? state.groups.map(groupCard).join('') : '
No private groups yet.
'}
`; - $('#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 `

${esc(group.name)}

-
-
${group.members.map(member => `${esc(member.username)} - ${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` ` : ''}`).join('')}
- ${owner ? `
-
` : ''}
`; - } - 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 = '
LOADING YOUR ARCHIVE
'; 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'); diff --git a/bootstrap.php b/bootstrap.php index d1827b2..7480b56 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -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 . '%'; } diff --git a/config.json b/config.json index 37edbd2..5460c55 100644 --- a/config.json +++ b/config.json @@ -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", diff --git a/embed-example.html b/embed-example.html index ca91026..66f8108 100644 --- a/embed-example.html +++ b/embed-example.html @@ -6,7 +6,7 @@ CyberChat — Embed Example - +