Groups is fully removed:

No homepage navigation, Account feature, tier setting, API, or client 
code.
Legacy group tables, entitlements, messages, voice files, and config are 
purged automatically.
New cache-busted client: cyberchat-app.js?v=20260609.3.
Browser and SQLite migration tests passed with no console errors.
This commit is contained in:
Ty Clifford
2026-06-09 00:16:32 -04:00
parent ccdbc47642
commit a1971f9515
11 changed files with 143 additions and 1110 deletions
+9
View File
@@ -11,6 +11,15 @@
</IfModule> </IfModule>
</FilesMatch> </FilesMatch>
# Always revalidate HTML so new asset versions take effect immediately.
<IfModule mod_headers.c>
<FilesMatch "\.(html?)$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
# Deny direct access to storage and internal PHP libraries # Deny direct access to storage and internal PHP libraries
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
+3 -3
View File
@@ -138,12 +138,12 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a
## Embedding ## Embedding
```html ```html
<link rel="stylesheet" href="/chat/assets/css/cyberchat.css"> <link rel="stylesheet" href="/chat/assets/css/cyberchat.css?v=20260609.3">
<div id="my-chat" style="width:100%;height:600px"></div> <div id="my-chat" style="width:100%;height:600px"></div>
<script> <script>
window.CYBERCHAT_BASE = '/chat'; window.CYBERCHAT_BASE = '/chat';
</script> </script>
<script src="/chat/assets/js/cyberchat-v4.js"></script> <script src="/chat/assets/js/cyberchat-app.js?v=20260609.3"></script>
<script> <script>
CyberChat.init('#my-chat'); CyberChat.init('#my-chat');
</script> </script>
@@ -163,7 +163,7 @@ api/auth.php Registration, login, 2FA, sessions
api/billing.php Stripe checkout, portal, and cancellation api/billing.php Stripe checkout, portal, and cancellation
api/messages.php Text, voice, polling, and recording presence api/messages.php Text, voice, polling, and recording presence
api/stripe-webhook.php Stripe event synchronization api/stripe-webhook.php Stripe event synchronization
assets/js/cyberchat-v4.js Browser application assets/js/cyberchat-app.js Browser application
lib/mailer.php Reusable Mailgun sender lib/mailer.php Reusable Mailgun sender
lib/stripe.php Stripe REST client and subscription sync lib/stripe.php Stripe REST client and subscription sync
lib/mysql_mirror.php Optional SQLite-to-MySQL mirror lib/mysql_mirror.php Optional SQLite-to-MySQL mirror
+31 -45
View File
@@ -25,7 +25,7 @@ session_start();
function cfg(): array { function cfg(): array {
static $c = null; static $c = null;
if ($c === null) $c = json_decode(file_get_contents(CONFIG_FILE), true) ?? []; if ($c === null) $c = getConfig();
return $c; return $c;
} }
@@ -52,11 +52,6 @@ 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 { function archiveDB(string $year, string $month, string $day): ?PDO {
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite'); $tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl)); $path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
@@ -65,6 +60,8 @@ function archiveDB(string $year, string $month, string $day): ?PDO {
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
ensureAdminMessageColumns($a); ensureAdminMessageColumns($a);
removeLegacyGroupSchema($a, false);
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
return $a; return $a;
} }
@@ -91,10 +88,12 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
day_key TEXT NOT NULL day_key TEXT NOT NULL
)"); )");
ensureAdminMessageColumns($a); ensureAdminMessageColumns($a);
removeLegacyGroupSchema($a, false);
$a->exec("CREATE TABLE IF NOT EXISTS archive_meta ( $a->exec("CREATE TABLE IF NOT EXISTS archive_meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
)"); )");
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
return $a; return $a;
} }
@@ -155,9 +154,7 @@ function refreshArchiveMeta(PDO $archive): void {
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
)"); )");
$count = (int)$archive->query( $count = (int)$archive->query("SELECT COUNT(*) FROM messages")->fetchColumn();
"SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($archive)
)->fetchColumn();
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)"); $meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)");
$meta->execute(['archived_at', (string)time()]); $meta->execute(['archived_at', (string)time()]);
$meta->execute(['message_count', (string)$count]); $meta->execute(['message_count', (string)$count]);
@@ -199,8 +196,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'archive_voice_clip') { if ($act === 'archive_voice_clip') {
$id = (int)($_POST['msg_id'] ?? 0); $id = (int)($_POST['msg_id'] ?? 0);
$live = db(); $live = db();
$stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice' AND " $stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
. adminPublicMessagePredicate($live));
$stmt->execute([$id]); $stmt->execute([$id]);
$row = $stmt->fetch(); $row = $stmt->fetch();
if (!$row) { if (!$row) {
@@ -241,8 +237,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($adb->inTransaction()) $adb->rollBack(); if ($adb->inTransaction()) $adb->rollBack();
throw $e; throw $e;
} }
$delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice' AND " $delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
. adminPublicMessagePredicate($live));
$delete->execute([$id]); $delete->execute([$id]);
if ($delete->rowCount() !== 1) { if ($delete->rowCount() !== 1) {
throw new RuntimeException('The live clip changed before it could be removed.'); throw new RuntimeException('The live clip changed before it could be removed.');
@@ -259,11 +254,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d' $src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
if ($src === 'live') { if ($src === 'live') {
$live = db(); $live = db();
$public = adminPublicMessagePredicate($live); $row = $live->prepare("SELECT voice_file FROM messages WHERE id = ?");
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ? AND $public");
$row->execute([$id]); $row->execute([$id]);
deleteRecordingsForRows($row->fetchAll()); deleteRecordingsForRows($row->fetchAll());
$live->prepare("DELETE FROM messages WHERE id = ? AND $public")->execute([$id]); $live->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
flash("Message #$id deleted."); flash("Message #$id deleted.");
} else { } else {
[$_, $day] = explode(':', $src, 2); [$_, $day] = explode(':', $src, 2);
@@ -295,8 +289,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if ($src === 'live') { if ($src === 'live') {
$live = db(); $live = db();
$live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ? AND " $live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")
. adminPublicMessagePredicate($live))->execute([$txt, $username, $id]); ->execute([$txt, $username, $id]);
flash("Message #$id updated."); flash("Message #$id updated.");
} else { } else {
[$_, $day] = explode(':', $src, 2); [$_, $day] = explode(':', $src, 2);
@@ -314,12 +308,11 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$ids = array_map('intval', $_POST['msg_ids'] ?? []); $ids = array_map('intval', $_POST['msg_ids'] ?? []);
if ($ids) { if ($ids) {
$live = db(); $live = db();
$public = adminPublicMessagePredicate($live);
$ph = implode(',', array_fill(0, count($ids), '?')); $ph = implode(',', array_fill(0, count($ids), '?'));
$rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph) AND $public"); $rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)");
$rows->execute($ids); $rows->execute($ids);
deleteRecordingsForRows($rows->fetchAll()); deleteRecordingsForRows($rows->fetchAll());
$live->prepare("DELETE FROM messages WHERE id IN ($ph) AND $public")->execute($ids); $live->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
flash(count($ids) . ' message(s) deleted.'); flash(count($ids) . ' message(s) deleted.');
} }
redirect($_POST['return_qs'] ?? ''); redirect($_POST['return_qs'] ?? '');
@@ -328,11 +321,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'delete_day_live') { if ($act === 'delete_day_live') {
$day = $_POST['day_key']; $day = $_POST['day_key'];
$live = db(); $live = db();
$public = adminPublicMessagePredicate($live); $rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ?");
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ? AND $public");
$rows->execute([$day]); $rows->execute([$day]);
deleteRecordingsForRows($rows->fetchAll()); deleteRecordingsForRows($rows->fetchAll());
$st = $live->prepare("DELETE FROM messages WHERE day_key = ? AND $public"); $st = $live->prepare("DELETE FROM messages WHERE day_key = ?");
$st->execute([$day]); $st->execute([$day]);
flash("All messages for $day deleted (" . $st->rowCount() . " rows)."); flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
redirect('tab=messages'); redirect('tab=messages');
@@ -356,9 +348,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'clear_all_live') { if ($act === 'clear_all_live') {
$live = db(); $live = db();
$public = adminPublicMessagePredicate($live); deleteRecordingsForRows($live->query("SELECT voice_file FROM messages")->fetchAll());
deleteRecordingsForRows($live->query("SELECT voice_file FROM messages WHERE $public")->fetchAll()); $live->exec("DELETE FROM messages");
$live->exec("DELETE FROM messages WHERE $public");
flash("All live messages cleared."); flash("All live messages cleared.");
redirect('tab=messages'); redirect('tab=messages');
} }
@@ -474,12 +465,11 @@ $stats = [];
if ($isLoggedIn) { if ($isLoggedIn) {
try { try {
$live = db(); $live = db();
$public = adminPublicMessagePredicate($live);
$stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn(); $stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn();
$stats['messages'] = $live->query("SELECT COUNT(*) FROM messages WHERE $public")->fetchColumn(); $stats['messages'] = $live->query("SELECT COUNT(*) FROM messages")->fetchColumn();
$stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice' AND $public")->fetchColumn(); $stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn();
$stats['sessions'] = $live->query("SELECT COUNT(*) FROM sessions")->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(); $stats['today'] = $live->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->fetchColumn();
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600; $timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn(); $stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; } } catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
@@ -496,8 +486,8 @@ if ($isLoggedIn) {
$sz = filesize($f); $sz = filesize($f);
$cnt = 0; $cnt = 0;
try { try {
$x = new PDO('sqlite:'.$f); $x = archiveDB($m[1], $m[2], $m[3]);
$cnt = $x->query("SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($x))->fetchColumn(); if ($x) $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn();
} catch(Exception $e) {} } catch(Exception $e) {}
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]]; $archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
} }
@@ -969,7 +959,7 @@ td.actions{white-space:nowrap;width:1px}
<?php <?php
$live = db(); $live = db();
$recent = $live->query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id $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(); ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
if (empty($recent)): ?> if (empty($recent)): ?>
<div class="empty">NO MESSAGES YET</div> <div class="empty">NO MESSAGES YET</div>
<?php else: ?> <?php else: ?>
@@ -1048,7 +1038,7 @@ td.actions{white-space:nowrap;width:1px}
$fType = trim($_GET['type'] ?? ''); $fType = trim($_GET['type'] ?? '');
$live = db(); $live = db();
$where = [adminPublicMessagePredicate($live)]; $params = []; $where = []; $params = [];
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; } if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; } if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; } if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; }
@@ -1057,8 +1047,7 @@ td.actions{white-space:nowrap;width:1px}
$msgs = $live->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll(); $msgs = $live->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
// Days available // Days available
$days = $live->query("SELECT DISTINCT day_key FROM messages WHERE " $days = $live->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
?> ?>
<div class="search-bar"> <div class="search-bar">
@@ -1203,7 +1192,7 @@ td.actions{white-space:nowrap;width:1px}
$voiceRows = []; $voiceRows = [];
if ($vSource !== 'archive') { if ($vSource !== 'archive') {
$live = db(); $live = db();
$vWhere = ["message_type = 'voice'", adminPublicMessagePredicate($live)]; $vWhere = ["message_type = 'voice'"];
$vParams = []; $vParams = [];
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; } if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; } if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; }
@@ -1221,8 +1210,7 @@ td.actions{white-space:nowrap;width:1px}
try { try {
$vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']); $vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']);
if (!$vAdb) continue; if (!$vAdb) continue;
$vSql = "SELECT * FROM messages WHERE message_type = 'voice' AND " $vSql = "SELECT * FROM messages WHERE message_type = 'voice'";
. adminPublicMessagePredicate($vAdb);
$vParams = []; $vParams = [];
if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; } if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
$vSql .= ' ORDER BY created_at DESC LIMIT 500'; $vSql .= ' ORDER BY created_at DESC LIMIT 500';
@@ -1242,8 +1230,7 @@ td.actions{white-space:nowrap;width:1px}
$voiceRows = array_slice($voiceRows, 0, 500); $voiceRows = array_slice($voiceRows, 0, 500);
$live = db(); $live = db();
$voiceDays = array_unique(array_merge( $voiceDays = array_unique(array_merge(
$live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' AND " $live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
array_column($archives, 'day') array_column($archives, 'day')
)); ));
rsort($voiceDays); rsort($voiceDays);
@@ -1356,7 +1343,7 @@ td.actions{white-space:nowrap;width:1px}
if ($adb) { if ($adb) {
$fAUser = trim($_GET['au'] ?? ''); $fAUser = trim($_GET['au'] ?? '');
$fAText = trim($_GET['at'] ?? ''); $fAText = trim($_GET['at'] ?? '');
$awhere = [adminPublicMessagePredicate($adb)]; $aparams = []; $awhere = []; $aparams = [];
if ($fAUser) { $awhere[] = "username LIKE ?"; $aparams[] = '%'.$fAUser.'%'; } if ($fAUser) { $awhere[] = "username LIKE ?"; $aparams[] = '%'.$fAUser.'%'; }
if ($fAText) { $awhere[] = "message LIKE ?"; $aparams[] = '%'.$fAText.'%'; } if ($fAText) { $awhere[] = "message LIKE ?"; $aparams[] = '%'.$fAText.'%'; }
$asql = "SELECT * FROM messages" . ($awhere ? " WHERE ".implode(" AND ", $awhere) : "") . " ORDER BY created_at DESC LIMIT 500"; $asql = "SELECT * FROM messages" . ($awhere ? " WHERE ".implode(" AND ", $awhere) : "") . " ORDER BY created_at DESC LIMIT 500";
@@ -1539,8 +1526,7 @@ td.actions{white-space:nowrap;width:1px}
$live = db(); $live = db();
$allUsers = $live->query(" $allUsers = $live->query("
SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name, 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 AND " (SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count,
. adminPublicMessagePredicate($live, 'm') . ") as msg_count,
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
FROM users u FROM users u
LEFT JOIN plans p ON p.id=u.plan_id LEFT JOIN plans p ON p.id=u.plan_id
+2 -7
View File
@@ -30,10 +30,6 @@ function messagePayload(array $row): array {
]; ];
} }
function publicMessageCondition(PDO $db): string {
return isset(tableColumns($db, 'messages')['group_id']) ? ' AND group_id IS NULL' : '';
}
function sendTextMessage(): never { function sendTextMessage(): never {
$user = authRequired(); $user = authRequired();
$message = trim((string)($_POST['message'] ?? '')); $message = trim((string)($_POST['message'] ?? ''));
@@ -155,17 +151,16 @@ function pollMessages(): never {
$since = max(0, (int)($_GET['since'] ?? 0)); $since = max(0, (int)($_GET['since'] ?? 0));
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100))); $limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
$db = getDB(); $db = getDB();
$publicOnly = publicMessageCondition($db);
if ($since === 0) { if ($since === 0) {
$stmt = $db->prepare("SELECT * FROM messages $stmt = $db->prepare("SELECT * FROM messages
WHERE day_key=?$publicOnly ORDER BY id DESC LIMIT ?"); WHERE day_key=? ORDER BY id DESC LIMIT ?");
$stmt->execute([dayKey(), $limit]); $stmt->execute([dayKey(), $limit]);
$rows = array_reverse($stmt->fetchAll()); $rows = array_reverse($stmt->fetchAll());
$hasMore = false; $hasMore = false;
} else { } else {
$stmt = $db->prepare("SELECT * FROM messages $stmt = $db->prepare("SELECT * FROM messages
WHERE day_key=?$publicOnly AND id>? ORDER BY id LIMIT ?"); WHERE day_key=? AND id>? ORDER BY id LIMIT ?");
$stmt->execute([dayKey(), $since, $limit + 1]); $stmt->execute([dayKey(), $since, $limit + 1]);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
$hasMore = count($rows) > $limit; $hasMore = count($rows) > $limit;
-3
View File
@@ -1178,9 +1178,6 @@ html, body {
.cc-inline-btn.danger { color: var(--r); border-color: var(--r); } .cc-inline-btn.danger { color: var(--r); border-color: var(--r); }
.cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; } .cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; }
.cc-actions { display: flex; gap: 6px; flex-wrap: wrap; } .cc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.cc-member-list { display: flex; gap: 6px; flex-wrap: wrap; }
.cc-member-list span { padding: 4px 7px; border: 1px solid var(--bd); border-radius: 2px; font: 10px var(--mono); }
.cc-member-list button { color: var(--r); background: none; border: 0; margin-left: 4px; }
.cc-empty-card { padding: 30px; color: var(--t1); border: 1px dashed var(--bd); text-align: center; font: 10px var(--mono); } .cc-empty-card { padding: 30px; color: var(--t1); border: 1px dashed var(--bd); text-align: center; font: 10px var(--mono); }
.cc-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; } .cc-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; }
.cc-archive-list { display: flex; flex-direction: column; gap: 6px; } .cc-archive-list { display: flex; flex-direction: column; gap: 6px; }
@@ -19,6 +19,11 @@
}; };
let root; let root;
let wakeListenersBound = false; let wakeListenersBound = false;
const displayedFeatures = new Set([
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play',
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
'voice_export', 'email_2fa'
]);
const $ = (s, c) => (c || root).querySelector(s); const $ = (s, c) => (c || root).querySelector(s);
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s)); const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
const esc = value => String(value == null ? '' : value) const esc = value => String(value == null ? '' : value)
@@ -27,8 +32,8 @@
function base() { function base() {
if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, ''); if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, '');
const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-v4.js')); const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-app.js'));
return script ? script.src.replace(/\/assets\/js\/cyberchat-v4\.js.*$/, '') : ''; return script ? script.src.replace(/\/assets\/js\/cyberchat-app\.js.*$/, '') : '';
} }
function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); } function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); }
function publicUrl(path) { function publicUrl(path) {
@@ -54,6 +59,10 @@
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key) return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
? state.user.features[key] : fallback; ? state.user.features[key] : fallback;
} }
function visibleFeatures() {
return Object.entries(state.user?.features || {})
.filter(([key]) => displayedFeatures.has(String(key).toLowerCase()));
}
function toast(message, type = 'error') { function toast(message, type = 'error') {
const area = $('#cc-toasts'); const area = $('#cc-toasts');
if (!area) return; if (!area) return;
@@ -681,7 +690,7 @@
</section></div> </section></div>
${showBilling ? billingHtml(billing) : ''} ${showBilling ? billingHtml(billing) : ''}
<section class="cc-card cc-feature-card"><h3>CURRENT FEATURES</h3><div class="cc-feature-list"> <section class="cc-card cc-feature-card"><h3>CURRENT FEATURES</h3><div class="cc-feature-list">
${Object.entries(state.user.features).map(([key, value]) => `<span><b>${esc(key.replace(/_/g, ' '))}</b> ${esc(String(value))}</span>`).join('')}</div></section> ${visibleFeatures().map(([key, value]) => `<span><b>${esc(key.replace(/_/g, ' '))}</b> ${esc(String(value))}</span>`).join('')}</div></section>
</div>`; </div>`;
$('#logout').addEventListener('click', () => logout(true)); $('#logout').addEventListener('click', () => logout(true));
$('#email-send').addEventListener('click', requestEmail); $('#email-send').addEventListener('click', requestEmail);
File diff suppressed because it is too large Load Diff
+71 -16
View File
@@ -56,6 +56,13 @@ function getConfig(bool $reload = false): array {
if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found'); if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found');
$config = json_decode((string)file_get_contents(CONFIG_FILE), true); $config = json_decode((string)file_get_contents(CONFIG_FILE), true);
if (!is_array($config)) throw new RuntimeException('config.json is invalid: ' . json_last_error_msg()); if (!is_array($config)) throw new RuntimeException('config.json is invalid: ' . json_last_error_msg());
if (array_key_exists('groups', $config)) {
unset($config['groups']);
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json !== false && is_writable(CONFIG_FILE)) {
file_put_contents(CONFIG_FILE, $json . PHP_EOL, LOCK_EX);
}
}
} }
return $config; return $config;
} }
@@ -165,6 +172,51 @@ function ensureColumns(PDO $db, string $table, array $additions): void {
} }
} }
function removeLegacyGroupSchema(PDO $db, bool $mainDatabase): void {
$messageColumns = tableColumns($db, 'messages');
if (isset($messageColumns['group_id'])) {
$voiceFiles = $db->query("SELECT DISTINCT voice_file FROM messages
WHERE group_id IS NOT NULL AND voice_file IS NOT NULL AND voice_file != ''")->fetchAll(PDO::FETCH_COLUMN);
$db->exec('PRAGMA foreign_keys=OFF');
try {
$db->beginTransaction();
$db->exec("DROP TABLE IF EXISTS messages_without_groups");
$db->exec("CREATE TABLE messages_without_groups (
id INTEGER PRIMARY KEY" . ($mainDatabase ? ' AUTOINCREMENT' : '') . ",
user_id INTEGER NOT NULL,
username TEXT NOT NULL,
color TEXT NOT NULL,
message TEXT NOT NULL,
message_type TEXT NOT NULL DEFAULT 'text',
voice_file TEXT,
voice_mime TEXT,
voice_duration REAL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
day_key TEXT NOT NULL" .
($mainDatabase ? ", FOREIGN KEY(user_id) REFERENCES users(id)" : '') . "
)");
$db->exec("INSERT INTO messages_without_groups
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key
FROM messages WHERE group_id IS NULL");
$db->exec("DROP TABLE messages");
$db->exec("ALTER TABLE messages_without_groups RENAME TO messages");
$db->exec("DROP TABLE IF EXISTS group_members");
$db->exec("DROP TABLE IF EXISTS chat_groups");
$db->commit();
foreach ($voiceFiles as $voiceFile) deleteVoiceFile((string)$voiceFile);
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
} finally {
$db->exec('PRAGMA foreign_keys=ON');
}
} else {
$db->exec("DROP TABLE IF EXISTS group_members");
$db->exec("DROP TABLE IF EXISTS chat_groups");
}
}
function initDB(PDO $db): void { function initDB(PDO $db): void {
$db->exec("CREATE TABLE IF NOT EXISTS users ( $db->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -226,6 +278,7 @@ function initDB(PDO $db): void {
'voice_mime' => 'TEXT', 'voice_mime' => 'TEXT',
'voice_duration' => 'REAL', 'voice_duration' => 'REAL',
]); ]);
removeLegacyGroupSchema($db, true);
$db->exec("CREATE TABLE IF NOT EXISTS plans ( $db->exec("CREATE TABLE IF NOT EXISTS plans (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -368,11 +421,9 @@ function seedPlans(PDO $db): void {
$insertFeature->execute([$planId, $key, json_encode($value)]); $insertFeature->execute([$planId, $key, json_encode($value)]);
} }
} }
$db->exec("DELETE FROM plan_features WHERE feature_key IN ('groups_available','group_member_limit')"); $db->exec("DELETE FROM plan_features WHERE feature_key LIKE 'group%'");
$db->exec("UPDATE plans SET description='Premium voice, identity, security, and 90-day personal archives.' $db->exec("UPDATE plans SET description='Configurable premium chat features.'
WHERE slug='plus' AND description='Premium voice, identity, security, and groups for four.'"); WHERE lower(description) LIKE '%group%'");
$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(); $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]); $db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
} }
@@ -388,6 +439,7 @@ function plans(bool $activeOnly = false): array {
foreach ($featureStmt->fetchAll() as $feature) { foreach ($featureStmt->fetchAll() as $feature) {
$row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true); $row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true);
} }
$row['features'] = visiblePlanFeatures($row['features']);
$row['id'] = (int)$row['id']; $row['id'] = (int)$row['id'];
$row['price_cents'] = (int)$row['price_cents']; $row['price_cents'] = (int)$row['price_cents'];
$row['active'] = (bool)$row['active']; $row['active'] = (bool)$row['active'];
@@ -395,6 +447,14 @@ function plans(bool $activeOnly = false): array {
return $rows; return $rows;
} }
function visiblePlanFeatures(array $features): array {
return array_filter(
$features,
fn(string $key): bool => !str_starts_with(strtolower($key), 'group'),
ARRAY_FILTER_USE_KEY
);
}
function planById(int $planId): ?array { function planById(int $planId): ?array {
foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan; foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan;
return null; return null;
@@ -425,7 +485,7 @@ function userPublicPayload(array $user): array {
'email_verified' => !empty($user['email_verified_at']), 'email_verified' => !empty($user['email_verified_at']),
'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']], 'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']],
'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing', 'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing',
'features' => $plan['features'], 'features' => visiblePlanFeatures($plan['features']),
'subscription' => [ 'subscription' => [
'status' => $user['subscription_status'] ?? 'free', 'status' => $user['subscription_status'] ?? 'free',
'period_end' => isset($user['subscription_period_end']) ? (int)$user['subscription_period_end'] : null, 'period_end' => isset($user['subscription_period_end']) ? (int)$user['subscription_period_end'] : null,
@@ -506,11 +566,6 @@ function dayKey(): string {
return date('Y-m-d'); return date('Y-m-d');
} }
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 { function voiceUploadDir(): string {
return storagePath((string)getConfigVal('voice.upload_dir', 'uploads/voice')); return storagePath((string)getConfigVal('voice.upload_dir', 'uploads/voice'));
} }
@@ -544,6 +599,7 @@ function getArchiveDB(string $year, string $month, string $day): PDO {
'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
]); ]);
removeLegacyGroupSchema($db, false);
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)"); $db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
return $db; return $db;
@@ -567,8 +623,7 @@ function archiveYesterdayIfNeeded(): void {
$flag = ROOT_DIR . '/db/.archived_' . $yesterday; $flag = ROOT_DIR . '/db/.archived_' . $yesterday;
if (file_exists($flag)) return; if (file_exists($flag)) return;
$db = getDB(); $db = getDB();
$publicOnly = publicMessagesOnly($db); $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id");
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=?$publicOnly ORDER BY id");
$stmt->execute([$yesterday]); $stmt->execute([$yesterday]);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
if ($rows) { if ($rows) {
@@ -590,7 +645,7 @@ function archiveYesterdayIfNeeded(): void {
$meta->execute(['message_count', (string)count($rows)]); $meta->execute(['message_count', (string)count($rows)]);
$archive->commit(); $archive->commit();
} }
$db->prepare("DELETE FROM messages WHERE day_key=?$publicOnly")->execute([$yesterday]); $db->prepare("DELETE FROM messages WHERE day_key=?")->execute([$yesterday]);
file_put_contents($flag, date('c'), LOCK_EX); file_put_contents($flag, date('c'), LOCK_EX);
} }
@@ -600,7 +655,7 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
$includeVoice = (bool)featureValue($user, 'voice_archive', false); $includeVoice = (bool)featureValue($user, 'voice_archive', false);
$rows = []; $rows = [];
$db = getDB(); $db = getDB();
$sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($db); $sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
$params = [$user['id'], $cutoff]; $params = [$user['id'], $cutoff];
if (!$includeVoice) $sql .= " AND message_type='text'"; if (!$includeVoice) $sql .= " AND message_type='text'";
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; } if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; }
@@ -611,7 +666,7 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) { foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
$archive = getArchiveDB($year, $month, $day); $archive = getArchiveDB($year, $month, $day);
$archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($archive); $archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
$archiveParams = [$user['id'], $cutoff]; $archiveParams = [$user['id'], $cutoff];
if (!$includeVoice) $archiveSql .= " AND message_type='text'"; if (!$includeVoice) $archiveSql .= " AND message_type='text'";
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; } if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
+4 -4
View File
@@ -6,7 +6,7 @@
<title>CyberChat — Embed Example</title> <title>CyberChat — Embed Example</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <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 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.6"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3">
<style> <style>
html, body { html, body {
height: auto; 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="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="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.6"</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=20260609.3"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&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="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="cm">&lt;!-- 4. Script + init --&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</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.3"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&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> 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>); CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script> <script src="assets/js/cyberchat-app.js?v=20260609.3"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here'); CyberChat.init('#chat-here');
+5 -2
View File
@@ -2,19 +2,22 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="description" content="CyberChat — Secure Real-Time Chat"> <meta name="description" content="CyberChat — Secure Real-Time Chat">
<title>Chat @ TyClifford.com</title> <title>Chat @ TyClifford.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <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 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.6"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script> <script src="assets/js/cyberchat-app.js?v=20260609.3"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app'); CyberChat.init('#app');
+6
View File
@@ -27,6 +27,12 @@ server {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
location ~* \.html?$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# Never execute files from the voice upload directory # Never execute files from the voice upload directory
location ~ ^/uploads/voice/.*\.(php|phtml|phar|cgi|pl|py|sh)$ { location ~ ^/uploads/voice/.*\.(php|phtml|phar|cgi|pl|py|sh)$ {
deny all; deny all;