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);
?>
@@ -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}
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