-
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:
@@ -11,6 +11,15 @@
|
||||
</IfModule>
|
||||
</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
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
@@ -138,12 +138,12 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a
|
||||
## Embedding
|
||||
|
||||
```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>
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '/chat';
|
||||
</script>
|
||||
<script src="/chat/assets/js/cyberchat-v4.js"></script>
|
||||
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.3"></script>
|
||||
<script>
|
||||
CyberChat.init('#my-chat');
|
||||
</script>
|
||||
@@ -163,7 +163,7 @@ api/auth.php Registration, login, 2FA, sessions
|
||||
api/billing.php Stripe checkout, portal, and cancellation
|
||||
api/messages.php Text, voice, polling, and recording presence
|
||||
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/stripe.php Stripe REST client and subscription sync
|
||||
lib/mysql_mirror.php Optional SQLite-to-MySQL mirror
|
||||
|
||||
@@ -25,7 +25,7 @@ session_start();
|
||||
|
||||
function cfg(): array {
|
||||
static $c = null;
|
||||
if ($c === null) $c = json_decode(file_get_contents(CONFIG_FILE), true) ?? [];
|
||||
if ($c === null) $c = getConfig();
|
||||
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 {
|
||||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
||||
$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_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
ensureAdminMessageColumns($a);
|
||||
removeLegacyGroupSchema($a, false);
|
||||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||||
return $a;
|
||||
}
|
||||
|
||||
@@ -91,10 +88,12 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
|
||||
day_key TEXT NOT NULL
|
||||
)");
|
||||
ensureAdminMessageColumns($a);
|
||||
removeLegacyGroupSchema($a, false);
|
||||
$a->exec("CREATE TABLE IF NOT EXISTS archive_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)");
|
||||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||||
return $a;
|
||||
}
|
||||
|
||||
@@ -155,9 +154,7 @@ function refreshArchiveMeta(PDO $archive): void {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)");
|
||||
$count = (int)$archive->query(
|
||||
"SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($archive)
|
||||
)->fetchColumn();
|
||||
$count = (int)$archive->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
||||
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)");
|
||||
$meta->execute(['archived_at', (string)time()]);
|
||||
$meta->execute(['message_count', (string)$count]);
|
||||
@@ -199,8 +196,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($act === 'archive_voice_clip') {
|
||||
$id = (int)($_POST['msg_id'] ?? 0);
|
||||
$live = db();
|
||||
$stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice' AND "
|
||||
. adminPublicMessagePredicate($live));
|
||||
$stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
@@ -241,8 +237,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($adb->inTransaction()) $adb->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
$delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice' AND "
|
||||
. adminPublicMessagePredicate($live));
|
||||
$delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
|
||||
$delete->execute([$id]);
|
||||
if ($delete->rowCount() !== 1) {
|
||||
throw new RuntimeException('The live clip changed before it could be removed.');
|
||||
@@ -259,11 +254,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
|
||||
if ($src === 'live') {
|
||||
$live = db();
|
||||
$public = adminPublicMessagePredicate($live);
|
||||
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ? AND $public");
|
||||
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ?");
|
||||
$row->execute([$id]);
|
||||
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.");
|
||||
} else {
|
||||
[$_, $day] = explode(':', $src, 2);
|
||||
@@ -295,8 +289,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
if ($src === 'live') {
|
||||
$live = db();
|
||||
$live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ? AND "
|
||||
. adminPublicMessagePredicate($live))->execute([$txt, $username, $id]);
|
||||
$live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")
|
||||
->execute([$txt, $username, $id]);
|
||||
flash("Message #$id updated.");
|
||||
} else {
|
||||
[$_, $day] = explode(':', $src, 2);
|
||||
@@ -314,12 +308,11 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$ids = array_map('intval', $_POST['msg_ids'] ?? []);
|
||||
if ($ids) {
|
||||
$live = db();
|
||||
$public = adminPublicMessagePredicate($live);
|
||||
$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);
|
||||
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.');
|
||||
}
|
||||
redirect($_POST['return_qs'] ?? '');
|
||||
@@ -328,11 +321,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($act === 'delete_day_live') {
|
||||
$day = $_POST['day_key'];
|
||||
$live = db();
|
||||
$public = adminPublicMessagePredicate($live);
|
||||
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ? AND $public");
|
||||
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ?");
|
||||
$rows->execute([$day]);
|
||||
deleteRecordingsForRows($rows->fetchAll());
|
||||
$st = $live->prepare("DELETE FROM messages WHERE day_key = ? AND $public");
|
||||
$st = $live->prepare("DELETE FROM messages WHERE day_key = ?");
|
||||
$st->execute([$day]);
|
||||
flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
|
||||
redirect('tab=messages');
|
||||
@@ -356,9 +348,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
if ($act === 'clear_all_live') {
|
||||
$live = db();
|
||||
$public = adminPublicMessagePredicate($live);
|
||||
deleteRecordingsForRows($live->query("SELECT voice_file FROM messages WHERE $public")->fetchAll());
|
||||
$live->exec("DELETE FROM messages WHERE $public");
|
||||
deleteRecordingsForRows($live->query("SELECT voice_file FROM messages")->fetchAll());
|
||||
$live->exec("DELETE FROM messages");
|
||||
flash("All live messages cleared.");
|
||||
redirect('tab=messages');
|
||||
}
|
||||
@@ -474,12 +465,11 @@ $stats = [];
|
||||
if ($isLoggedIn) {
|
||||
try {
|
||||
$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['messages'] = $live->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
||||
$stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn();
|
||||
$stats['sessions'] = $live->query("SELECT COUNT(*) FROM sessions")->fetchColumn();
|
||||
$stats['today'] = $live->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "' 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;
|
||||
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
|
||||
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
|
||||
@@ -496,8 +486,8 @@ if ($isLoggedIn) {
|
||||
$sz = filesize($f);
|
||||
$cnt = 0;
|
||||
try {
|
||||
$x = new PDO('sqlite:'.$f);
|
||||
$cnt = $x->query("SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($x))->fetchColumn();
|
||||
$x = archiveDB($m[1], $m[2], $m[3]);
|
||||
if ($x) $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
||||
} catch(Exception $e) {}
|
||||
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
|
||||
}
|
||||
@@ -969,7 +959,7 @@ td.actions{white-space:nowrap;width:1px}
|
||||
<?php
|
||||
$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();
|
||||
ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
|
||||
if (empty($recent)): ?>
|
||||
<div class="empty">NO MESSAGES YET</div>
|
||||
<?php else: ?>
|
||||
@@ -1048,7 +1038,7 @@ td.actions{white-space:nowrap;width:1px}
|
||||
$fType = trim($_GET['type'] ?? '');
|
||||
|
||||
$live = db();
|
||||
$where = [adminPublicMessagePredicate($live)]; $params = [];
|
||||
$where = []; $params = [];
|
||||
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
|
||||
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
|
||||
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();
|
||||
|
||||
// Days available
|
||||
$days = $live->query("SELECT DISTINCT day_key FROM messages WHERE "
|
||||
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$days = $live->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
|
||||
?>
|
||||
|
||||
<div class="search-bar">
|
||||
@@ -1203,7 +1192,7 @@ td.actions{white-space:nowrap;width:1px}
|
||||
$voiceRows = [];
|
||||
if ($vSource !== 'archive') {
|
||||
$live = db();
|
||||
$vWhere = ["message_type = 'voice'", adminPublicMessagePredicate($live)];
|
||||
$vWhere = ["message_type = 'voice'"];
|
||||
$vParams = [];
|
||||
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
||||
if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; }
|
||||
@@ -1221,8 +1210,7 @@ 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' AND "
|
||||
. adminPublicMessagePredicate($vAdb);
|
||||
$vSql = "SELECT * FROM messages WHERE message_type = 'voice'";
|
||||
$vParams = [];
|
||||
if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
||||
$vSql .= ' ORDER BY created_at DESC LIMIT 500';
|
||||
@@ -1242,8 +1230,7 @@ td.actions{white-space:nowrap;width:1px}
|
||||
$voiceRows = array_slice($voiceRows, 0, 500);
|
||||
$live = db();
|
||||
$voiceDays = array_unique(array_merge(
|
||||
$live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' AND "
|
||||
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
|
||||
$live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
|
||||
array_column($archives, 'day')
|
||||
));
|
||||
rsort($voiceDays);
|
||||
@@ -1356,7 +1343,7 @@ td.actions{white-space:nowrap;width:1px}
|
||||
if ($adb) {
|
||||
$fAUser = trim($_GET['au'] ?? '');
|
||||
$fAText = trim($_GET['at'] ?? '');
|
||||
$awhere = [adminPublicMessagePredicate($adb)]; $aparams = [];
|
||||
$awhere = []; $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";
|
||||
@@ -1539,8 +1526,7 @@ td.actions{white-space:nowrap;width:1px}
|
||||
$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 AND "
|
||||
. adminPublicMessagePredicate($live, 'm') . ") as msg_count,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count,
|
||||
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
|
||||
FROM users u
|
||||
LEFT JOIN plans p ON p.id=u.plan_id
|
||||
|
||||
+2
-7
@@ -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 {
|
||||
$user = authRequired();
|
||||
$message = trim((string)($_POST['message'] ?? ''));
|
||||
@@ -155,17 +151,16 @@ function pollMessages(): never {
|
||||
$since = max(0, (int)($_GET['since'] ?? 0));
|
||||
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
|
||||
$db = getDB();
|
||||
$publicOnly = publicMessageCondition($db);
|
||||
|
||||
if ($since === 0) {
|
||||
$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]);
|
||||
$rows = array_reverse($stmt->fetchAll());
|
||||
$hasMore = false;
|
||||
} else {
|
||||
$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]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$hasMore = count($rows) > $limit;
|
||||
|
||||
@@ -1178,9 +1178,6 @@ html, body {
|
||||
.cc-inline-btn.danger { color: var(--r); border-color: var(--r); }
|
||||
.cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.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-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; }
|
||||
.cc-archive-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
};
|
||||
let root;
|
||||
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) => Array.from((c || root).querySelectorAll(s));
|
||||
const esc = value => String(value == null ? '' : value)
|
||||
@@ -27,8 +32,8 @@
|
||||
|
||||
function base() {
|
||||
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'));
|
||||
return script ? script.src.replace(/\/assets\/js\/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-app\.js.*$/, '') : '';
|
||||
}
|
||||
function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); }
|
||||
function publicUrl(path) {
|
||||
@@ -54,6 +59,10 @@
|
||||
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
|
||||
? 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') {
|
||||
const area = $('#cc-toasts');
|
||||
if (!area) return;
|
||||
@@ -681,7 +690,7 @@
|
||||
</section></div>
|
||||
${showBilling ? billingHtml(billing) : ''}
|
||||
<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>`;
|
||||
$('#logout').addEventListener('click', () => logout(true));
|
||||
$('#email-send').addEventListener('click', requestEmail);
|
||||
File diff suppressed because it is too large
Load Diff
+71
-16
@@ -56,6 +56,13 @@ function getConfig(bool $reload = false): array {
|
||||
if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found');
|
||||
$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 (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;
|
||||
}
|
||||
@@ -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 {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -226,6 +278,7 @@ function initDB(PDO $db): void {
|
||||
'voice_mime' => 'TEXT',
|
||||
'voice_duration' => 'REAL',
|
||||
]);
|
||||
removeLegacyGroupSchema($db, true);
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -368,11 +421,9 @@ function seedPlans(PDO $db): void {
|
||||
$insertFeature->execute([$planId, $key, json_encode($value)]);
|
||||
}
|
||||
}
|
||||
$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.'");
|
||||
$db->exec("DELETE FROM plan_features WHERE feature_key LIKE 'group%'");
|
||||
$db->exec("UPDATE plans SET description='Configurable premium chat features.'
|
||||
WHERE lower(description) LIKE '%group%'");
|
||||
$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]);
|
||||
}
|
||||
@@ -388,6 +439,7 @@ function plans(bool $activeOnly = false): array {
|
||||
foreach ($featureStmt->fetchAll() as $feature) {
|
||||
$row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true);
|
||||
}
|
||||
$row['features'] = visiblePlanFeatures($row['features']);
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['price_cents'] = (int)$row['price_cents'];
|
||||
$row['active'] = (bool)$row['active'];
|
||||
@@ -395,6 +447,14 @@ function plans(bool $activeOnly = false): array {
|
||||
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 {
|
||||
foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan;
|
||||
return null;
|
||||
@@ -425,7 +485,7 @@ function userPublicPayload(array $user): array {
|
||||
'email_verified' => !empty($user['email_verified_at']),
|
||||
'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']],
|
||||
'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing',
|
||||
'features' => $plan['features'],
|
||||
'features' => visiblePlanFeatures($plan['features']),
|
||||
'subscription' => [
|
||||
'status' => $user['subscription_status'] ?? 'free',
|
||||
'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');
|
||||
}
|
||||
|
||||
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 {
|
||||
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'",
|
||||
'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 INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||||
return $db;
|
||||
@@ -567,8 +623,7 @@ function archiveYesterdayIfNeeded(): void {
|
||||
$flag = ROOT_DIR . '/db/.archived_' . $yesterday;
|
||||
if (file_exists($flag)) return;
|
||||
$db = getDB();
|
||||
$publicOnly = publicMessagesOnly($db);
|
||||
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=?$publicOnly ORDER BY id");
|
||||
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id");
|
||||
$stmt->execute([$yesterday]);
|
||||
$rows = $stmt->fetchAll();
|
||||
if ($rows) {
|
||||
@@ -590,7 +645,7 @@ function archiveYesterdayIfNeeded(): void {
|
||||
$meta->execute(['message_count', (string)count($rows)]);
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -600,7 +655,7 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
|
||||
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
|
||||
$rows = [];
|
||||
$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];
|
||||
if (!$includeVoice) $sql .= " AND message_type='text'";
|
||||
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]) {
|
||||
$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];
|
||||
if (!$includeVoice) $archiveSql .= " AND message_type='text'";
|
||||
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@
|
||||
<title>CyberChat — Embed Example</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.6">
|
||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3">
|
||||
<style>
|
||||
html, body {
|
||||
height: auto;
|
||||
@@ -88,13 +88,13 @@
|
||||
<span class="kw"><link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">></span>
|
||||
|
||||
<span class="cm"><!-- 2. CyberChat stylesheet --></span>
|
||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.6"</span><span class="kw">></span>
|
||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260609.3"</span><span class="kw">></span>
|
||||
|
||||
<span class="cm"><!-- 3. Container with any dimensions --></span>
|
||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||
|
||||
<span class="cm"><!-- 4. Script + init --></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.6"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.3"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script></span>
|
||||
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
||||
CyberChat.init(<span class="str">'#my-chat'</span>);
|
||||
@@ -104,7 +104,7 @@
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '';
|
||||
</script>
|
||||
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.3"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
CyberChat.init('#chat-here');
|
||||
|
||||
+5
-2
@@ -2,19 +2,22 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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="description" content="CyberChat — Secure Real-Time Chat">
|
||||
<title>Chat @ TyClifford.com</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.6">
|
||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '';
|
||||
</script>
|
||||
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.3"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
CyberChat.init('#app');
|
||||
|
||||
@@ -27,6 +27,12 @@ server {
|
||||
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
|
||||
location ~ ^/uploads/voice/.*\.(php|phtml|phar|cgi|pl|py|sh)$ {
|
||||
deny all;
|
||||
|
||||
Reference in New Issue
Block a user