From a1971f9515b3cc3cd956507fad31da2a7945bb9c Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Tue, 9 Jun 2026 00:16:32 -0400 Subject: [PATCH] - 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. --- .htaccess | 9 + README.md | 6 +- admin.php | 76 +- api/messages.php | 9 +- assets/css/cyberchat.css | 3 - .../js/{cyberchat-v4.js => cyberchat-app.js} | 15 +- assets/js/cyberchat.js | 1027 ----------------- bootstrap.php | 87 +- embed-example.html | 8 +- index.html | 7 +- nginx-example.conf | 6 + 11 files changed, 143 insertions(+), 1110 deletions(-) rename assets/js/{cyberchat-v4.js => cyberchat-app.js} (98%) delete mode 100644 assets/js/cyberchat.js diff --git a/.htaccess b/.htaccess index 945f93f..41521d2 100644 --- a/.htaccess +++ b/.htaccess @@ -11,6 +11,15 @@ +# Always revalidate HTML so new asset versions take effect immediately. + + + Header set Cache-Control "no-cache, no-store, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + + + # Deny direct access to storage and internal PHP libraries RewriteEngine On diff --git a/README.md b/README.md index 5a640b8..c519ab8 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,12 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a ## Embedding ```html - +
- + @@ -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 diff --git a/admin.php b/admin.php index a3fc22a..8405866 100644 --- a/admin.php +++ b/admin.php @@ -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} 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)): ?>
NO MESSAGES YET
@@ -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); ?> ${showBilling ? billingHtml(billing) : ''}

CURRENT FEATURES

- ${Object.entries(state.user.features).map(([key, value]) => `${esc(key.replace(/_/g, ' '))} ${esc(String(value))}`).join('')}
+ ${visibleFeatures().map(([key, value]) => `${esc(key.replace(/_/g, ' '))} ${esc(String(value))}`).join('')} `; $('#logout').addEventListener('click', () => logout(true)); $('#email-send').addEventListener('click', requestEmail); diff --git a/assets/js/cyberchat.js b/assets/js/cyberchat.js deleted file mode 100644 index a107806..0000000 --- a/assets/js/cyberchat.js +++ /dev/null @@ -1,1027 +0,0 @@ -/** - * CyberChat Client v3.0 - * Embed-ready real-time chat with registration & login - */ - -(function(window, document) { - 'use strict'; - - // ─── State ──────────────────────────────────────────────────────────────── - const state = { - config: null, - auth: { authenticated: false, username: null, color: null }, - chat: { lastId: 0, pollTimer: null, polling: false }, - voice: { - recorder: null, stream: null, timer: null, startedAt: 0, - chunks: [], blob: null, duration: 0, fallback: null, - autoPlay: true, starting: false, queue: [], current: null, - playbackBlocked: false - }, - ui: { darkMode: true, atBottom: true, screen: 'loading' }, - online: 0, - }; - - // ─── DOM refs ───────────────────────────────────────────────────────────── - let root, msgList, msgInput, charCount, sendBtn, scrollBtn, - toastArea, onlineBadge, connStatus, voiceRecordBtn, voiceStatus, - voiceReview, voicePreview, voiceSendBtn, voiceDiscardBtn, - voiceQueueBtn, voiceQueueCount; - - // ─── Utils ──────────────────────────────────────────────────────────────── - const $ = (sel, ctx) => (ctx || root).querySelector(sel); - const esc = str => String(str) - .replace(/&/g,'&').replace(//g,'>') - .replace(/"/g,'"').replace(/'/g,'''); - - function apiPath(ep) { - // Auto-detect base path from the script tag if CYBERCHAT_BASE not explicitly set - if (!window._CC_BASE_RESOLVED) { - window._CC_BASE_RESOLVED = true; - if (!window.CYBERCHAT_BASE) { - var scripts = document.querySelectorAll('script[src]'); - for (var i = 0; i < scripts.length; i++) { - var src = scripts[i].src; - if (src.indexOf('cyberchat.js') !== -1) { - // e.g. /chat/assets/js/cyberchat.js -> /chat - window.CYBERCHAT_BASE = src.replace(/\/assets\/js\/cyberchat\.js.*$/, ''); - break; - } - } - } - } - var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, ''); - return base + '/api/' + ep; - } - - function publicPath(path) { - if (/^(https?:)?\/\//.test(path || '') || String(path || '').startsWith('/')) return path; - var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, ''); - return base + '/' + String(path || '').replace(/^\//, ''); - } - - async function apiFetch(ep, opts) { - const res = await fetch(apiPath(ep), { credentials: 'include', ...(opts||{}) }); - if (!res.ok && res.status !== 400 && res.status !== 401 && res.status !== 403 && res.status !== 409) { - const text = await res.text().catch(() => ''); - throw new Error('HTTP ' + res.status + (text ? ': ' + text.slice(0, 120) : '')); - } - const ct = res.headers.get('content-type') || ''; - if (!ct.includes('application/json')) { - const text = await res.text().catch(() => ''); - throw new Error('Expected JSON but got: ' + text.slice(0, 160)); - } - return res.json(); - } - - function fmtTime(ts) { - return new Date(ts * 1000).toLocaleTimeString('en-US', - { hour:'2-digit', minute:'2-digit', hour12:false }); - } - - function linkify(str) { - return str.replace(/(https?:\/\/[^\s<>]+)/g, - '$1'); - } - - function saveTheme(dark) { - try { localStorage.setItem('cc_theme', dark ? 'dark' : 'light'); } catch(e){} - } - function loadTheme() { - try { return localStorage.getItem('cc_theme') !== 'light'; } catch(e){ return true; } - } - function loadVoiceAutoplay(defaultEnabled) { - try { - const saved = localStorage.getItem('cc_voice_autoplay'); - return saved === null ? !!defaultEnabled : saved === 'true'; - } catch(e) { - return !!defaultEnabled; - } - } - function saveVoiceAutoplay(enabled) { - try { localStorage.setItem('cc_voice_autoplay', enabled ? 'true' : 'false'); } catch(e){} - } - - function toast(msg, type) { - if (!toastArea) return; - const el = document.createElement('div'); - el.className = 'cc-toast ' + (type || 'error'); - el.textContent = msg; - toastArea.appendChild(el); - setTimeout(() => { el.classList.add('cc-toast-fade'); setTimeout(() => el.remove(), 350); }, 2800); - } - - // ─── Init ───────────────────────────────────────────────────────────────── - async function init(sel) { - root = typeof sel === 'string' ? document.querySelector(sel) : sel; - if (!root) { console.error('[CyberChat] Container not found:', sel); return; } - root.id = 'cyberchat-root'; - - // Load config from server - try { - const r = await fetch(apiPath('config.php'), { credentials: 'include' }); - state.config = await r.json(); - } catch(e) { - state.config = { - chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 }, - ui: { app_title: 'CYBERCHAT', app_subtitle: 'v3.0 // SECURE CHANNEL' }, - security: { min_password_length: 4 }, - colors: { user_palette: ['#00ff9f','#00b8ff','#ff2d78','#ff9f00','#ff3c3c'] }, - voice: { enabled: true, max_duration_seconds: 60, max_upload_bytes: 12582912, auto_play_default: true } - }; - } - - state.ui.darkMode = loadTheme(); - state.voice.autoPlay = loadVoiceAutoplay(state.config?.voice?.auto_play_default !== false); - if (!state.ui.darkMode) document.body.classList.add('cc-light'); - - renderShell(); - cacheRefs(); - bindGlobal(); - - // Check existing session - try { - const check = await apiFetch('auth.php?action=check'); - if (check.authenticated) { - state.auth = { authenticated: true, username: check.username, color: check.color }; - showChat(); - return; - } - } catch(e) {} - - showAuth('register'); // Default to register for new visitors - } - - // ─── Shell render (persistent chrome) ──────────────────────────────────── - function renderShell() { - const cfg = state.config; - const title = esc(cfg?.ui?.app_title || 'CYBERCHAT'); - const sub = esc(cfg?.ui?.app_subtitle || 'v3.0 // SECURE CHANNEL'); - - root.innerHTML = ` -
- -
- -
- - - online -
-
- -
-
- -
- -
- `; - } - - function cacheRefs() { - toastArea = $('#cc-toasts'); - onlineBadge = $('#cc-online-count'); - connStatus = $('#cc-conn-dot'); - } - - // ─── Auth screen ────────────────────────────────────────────────────────── - function showAuth(initialTab) { - stopPolling(); - state.ui.screen = 'auth'; - - const cfg = state.config; - const maxUser = cfg?.chat?.max_username_length || 12; - const minPass = cfg?.security?.min_password_length || 4; - const maxMsg = cfg?.chat?.max_message_length || 500; - const tab = initialTab || 'register'; - - $('#cc-body').classList.remove('cc-has-voice'); - $('#cc-body').innerHTML = ` -
-
- -
-
- IDENTIFY YOURSELF -
-
- -
- - -
- - -
-

New here? Choose your callsign and set a passphrase.

- -
- - -
- -
- - -
- -
- - -
- - -
- - -
-

Welcome back. Enter your callsign and passphrase.

- -
- - -
- -
- - -
- - -
- -
- -
-
- `; - - bindAuth(); - - // Autofocus first input - setTimeout(() => { - const first = tab === 'register' - ? $('#cc-reg-user') - : $('#cc-login-user'); - first && first.focus(); - }, 80); - } - - function bindAuth() { - // Tab switching - const tabs = root.querySelectorAll('.cc-auth-tab'); - tabs.forEach(t => { - t.addEventListener('click', () => { - const target = t.dataset.target; - const which = t.dataset.tab; - tabs.forEach(x => x.classList.remove('active')); - t.classList.add('active'); - root.querySelectorAll('.cc-auth-form').forEach(f => f.classList.remove('active')); - $('#' + target).classList.add('active'); - clearAuthErr(); - const inp = $('#' + target + ' .cc-input'); - inp && inp.focus(); - }); - }); - - // Register - const regBtn = $('#cc-reg-btn'); - if (regBtn) { - regBtn.addEventListener('click', doRegister); - ['cc-reg-user','cc-reg-pass','cc-reg-pass2'].forEach(id => { - const el = $('#' + id); - if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); }); - }); - } - - // Login - const loginBtn = $('#cc-login-btn'); - if (loginBtn) { - loginBtn.addEventListener('click', doLogin); - ['cc-login-user','cc-login-pass'].forEach(id => { - const el = $('#' + id); - if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); }); - }); - } - } - - function showAuthErr(msg) { - const el = $('#cc-auth-err'); - if (el) { el.textContent = '⚠ ' + msg; el.classList.add('visible'); } - } - function clearAuthErr() { - const el = $('#cc-auth-err'); - if (el) { el.textContent = ''; el.classList.remove('visible'); } - } - - async function doRegister() { - const user = ($('#cc-reg-user')?.value || '').trim(); - const pass = $('#cc-reg-pass')?.value || ''; - const pass2 = $('#cc-reg-pass2')?.value || ''; - const btn = $('#cc-reg-btn'); - const cfg = state.config; - const maxUser = cfg?.chat?.max_username_length || 12; - const minPass = cfg?.security?.min_password_length || 4; - - clearAuthErr(); - - if (!user) return showAuthErr('Please enter a callsign'); - if (user.length > maxUser) return showAuthErr(`Callsign max ${maxUser} characters`); - if (!/^[a-zA-Z0-9_\-]+$/.test(user)) return showAuthErr('Letters, numbers, _ and - only'); - if (!pass) return showAuthErr('Please enter a passphrase'); - if (pass.length < minPass) return showAuthErr(`Passphrase min ${minPass} characters`); - if (pass !== pass2) return showAuthErr('Passphrases do not match'); - - btn.disabled = true; - btn.querySelector('.cc-btn-inner').textContent = 'CREATING...'; - - const fd = new FormData(); - fd.append('action', 'register'); - fd.append('username', user); - fd.append('password', pass); - - try { - const res = await apiFetch('auth.php', { method:'POST', body:fd }); - if (res.success) { - state.auth = { authenticated: true, username: res.username, color: res.color }; - toast('Welcome, ' + res.username + '!', 'success'); - showChat(); - } else { - showAuthErr(res.error || 'Registration failed'); - btn.disabled = false; - btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY'; - } - } catch(e) { - showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP')); - btn.disabled = false; - btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY'; - } - } - - async function doLogin() { - const user = ($('#cc-login-user')?.value || '').trim(); - const pass = $('#cc-login-pass')?.value || ''; - const btn = $('#cc-login-btn'); - - clearAuthErr(); - - if (!user) return showAuthErr('Please enter your callsign'); - if (!pass) return showAuthErr('Please enter your passphrase'); - - btn.disabled = true; - btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATING...'; - - const fd = new FormData(); - fd.append('action', 'login'); - fd.append('username', user); - fd.append('password', pass); - - try { - const res = await apiFetch('auth.php', { method:'POST', body:fd }); - if (res.success) { - state.auth = { authenticated: true, username: res.username, color: res.color }; - toast('Access granted, ' + res.username, 'success'); - showChat(); - } else { - showAuthErr(res.error || 'Authentication failed'); - btn.disabled = false; - btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE'; - } - } catch(e) { - showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP')); - btn.disabled = false; - btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE'; - } - } - - // ─── Chat screen ────────────────────────────────────────────────────────── - function showChat() { - state.ui.screen = 'chat'; - const { username, color } = state.auth; - const maxMsg = state.config?.chat?.max_message_length || 500; - const voiceEnabled = state.config?.voice?.enabled !== false; - const voiceMax = state.config?.voice?.max_duration_seconds || 60; - const today = new Date().toLocaleDateString('en-US', - { weekday:'short', month:'short', day:'numeric', year:'numeric' }); - - $('#cc-body').classList.toggle('cc-has-voice', voiceEnabled); - $('#cc-body').innerHTML = ` -
-
- YOU: - - ${esc(username)} - - ${esc(today.toUpperCase())} -
-
- -
-
- -
-
-
- LOADING CHANNEL DATA... -
-
- - - - ${voiceEnabled ? ` -
- - VOICE CLIP · MAX ${voiceMax}s - - - -
` : ''} - -
- - ${maxMsg} - -
- `; - - // Cache - msgList = $('#cc-messages'); - msgInput = $('#cc-msg-input'); - charCount = $('#cc-char-count'); - sendBtn = $('#cc-send-btn'); - scrollBtn = $('#cc-scroll-btn'); - voiceRecordBtn = $('#cc-voice-record'); - voiceStatus = $('#cc-voice-status'); - voiceReview = $('#cc-voice-review'); - voicePreview = $('#cc-voice-preview'); - voiceSendBtn = $('#cc-voice-send'); - voiceDiscardBtn = $('#cc-voice-discard'); - voiceQueueBtn = $('#cc-voice-queue'); - voiceQueueCount = $('#cc-voice-queue-count'); - - bindChat(); - updateVoiceQueueUI(); - state.chat.lastId = 0; - startPolling(); - msgInput && msgInput.focus(); - } - - function bindChat() { - $('#cc-logout-btn')?.addEventListener('click', doLogout); - - msgInput?.addEventListener('input', updateCharCount); - msgInput?.addEventListener('keydown', e => { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } - }); - sendBtn?.addEventListener('click', doSend); - voiceRecordBtn?.addEventListener('click', toggleVoiceRecording); - voiceSendBtn?.addEventListener('click', sendVoiceClip); - voiceDiscardBtn?.addEventListener('click', discardVoiceClip); - voiceQueueBtn?.addEventListener('click', () => { - state.voice.playbackBlocked = false; - playNextVoice(); - }); - $('#cc-voice-autoplay')?.addEventListener('change', e => { - state.voice.autoPlay = !!e.target.checked; - saveVoiceAutoplay(state.voice.autoPlay); - if (state.voice.autoPlay) { - state.voice.playbackBlocked = false; - playNextVoice(); - } - updateVoiceQueueUI(); - }); - - msgList?.addEventListener('scroll', () => { - const el = msgList; - state.ui.atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40; - const show = !state.ui.atBottom && el.scrollHeight > el.clientHeight + 120; - scrollBtn?.classList.toggle('visible', show); - }); - - scrollBtn?.addEventListener('click', () => scrollToBottom(false)); - } - - function updateCharCount() { - const max = state.config?.chat?.max_message_length || 500; - const rem = max - (msgInput?.value?.length || 0); - if (!charCount) return; - charCount.textContent = rem; - charCount.className = 'cc-compose-counter' + - (rem < 20 ? ' danger' : rem < 60 ? ' warn' : ''); - } - - async function doLogout() { - discardVoiceClip(); - clearVoiceQueue(); - stopPolling(); - try { - const fd = new FormData(); - fd.append('action','logout'); - await apiFetch('auth.php', { method:'POST', body:fd }); - } catch(e){} - state.auth = { authenticated:false, username:null, color:null }; - state.chat.lastId = 0; - showAuth('login'); - } - - // ─── Voice clips ────────────────────────────────────────────────────────── - function supportedWebmMime() { - if (!window.MediaRecorder) return ''; - const options = ['audio/webm;codecs=opus', 'audio/webm']; - return options.find(type => !MediaRecorder.isTypeSupported || MediaRecorder.isTypeSupported(type)) || ''; - } - - async function toggleVoiceRecording() { - if (state.voice.starting) return; - if (state.voice.recorder || state.voice.fallback) { - stopVoiceCapture(false); - return; - } - discardVoiceClip(); - if (!navigator.mediaDevices?.getUserMedia) { - toast('Voice recording is not supported in this browser', 'error'); - return; - } - state.voice.starting = true; - voiceRecordBtn?.setAttribute('disabled', 'disabled'); - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - state.voice.stream = stream; - state.voice.startedAt = Date.now(); - state.voice.chunks = []; - const mime = supportedWebmMime(); - if (mime) { - try { - startMediaRecorder(stream, mime); - } catch (e) { - startWavRecorder(stream); - } - } else { - startWavRecorder(stream); - } - voiceRecordBtn?.classList.add('recording'); - const label = $('#cc-voice-record-label'); - if (label) label.textContent = 'STOP'; - updateVoiceTimer(); - state.voice.timer = setInterval(updateVoiceTimer, 200); - } catch (e) { - cleanupVoiceStream(); - toast(e?.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone', 'error'); - } finally { - state.voice.starting = false; - voiceRecordBtn?.removeAttribute('disabled'); - } - } - - function startMediaRecorder(stream, mime) { - const recorder = new MediaRecorder(stream, { mimeType: mime }); - recorder.addEventListener('dataavailable', e => { - if (e.data?.size) state.voice.chunks.push(e.data); - }); - recorder.addEventListener('stop', () => { - if (recorder._ccCanceled) { - state.voice.recorder = null; - return; - } - const duration = Math.min((Date.now() - state.voice.startedAt) / 1000, - state.config?.voice?.max_duration_seconds || 60); - const blob = new Blob(state.voice.chunks, { type: recorder.mimeType || mime || 'audio/webm' }); - finishVoiceClip(blob, duration); - state.voice.recorder = null; - }, { once: true }); - recorder.start(250); - state.voice.recorder = recorder; - } - - function startWavRecorder(stream) { - const AudioCtx = window.AudioContext || window.webkitAudioContext; - if (!AudioCtx) throw new Error('Audio recording unsupported'); - const context = new AudioCtx(); - const source = context.createMediaStreamSource(stream); - const processor = context.createScriptProcessor(4096, 1, 1); - const samples = []; - processor.onaudioprocess = e => samples.push(new Float32Array(e.inputBuffer.getChannelData(0))); - source.connect(processor); - processor.connect(context.destination); - state.voice.fallback = { context, source, processor, samples, sampleRate: context.sampleRate }; - } - - function updateVoiceTimer() { - const max = state.config?.voice?.max_duration_seconds || 60; - const elapsed = Math.min((Date.now() - state.voice.startedAt) / 1000, max); - if (voiceStatus) voiceStatus.textContent = 'RECORDING ' + formatDuration(elapsed) + ' / ' + formatDuration(max); - if (elapsed >= max) stopVoiceCapture(false); - } - - function stopVoiceCapture(cancel) { - clearInterval(state.voice.timer); - state.voice.timer = null; - const duration = state.voice.startedAt ? (Date.now() - state.voice.startedAt) / 1000 : 0; - if (state.voice.recorder && state.voice.recorder.state !== 'inactive') { - if (cancel) { - state.voice.recorder._ccCanceled = true; - state.voice.chunks = []; - } - state.voice.recorder.stop(); - } else if (state.voice.fallback) { - const wav = state.voice.fallback; - wav.processor.disconnect(); - wav.source.disconnect(); - wav.context.close(); - state.voice.fallback = null; - if (!cancel) finishVoiceClip(encodeWav(wav.samples, wav.sampleRate), duration); - } - cleanupVoiceStream(); - voiceRecordBtn?.classList.remove('recording'); - const label = $('#cc-voice-record-label'); - if (label) label.textContent = 'RECORD'; - if (cancel && voiceStatus) { - voiceStatus.textContent = 'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's'; - } - } - - function cleanupVoiceStream() { - state.voice.stream?.getTracks().forEach(track => track.stop()); - state.voice.stream = null; - } - - function finishVoiceClip(blob, duration) { - if (!blob?.size || duration < 0.25) { - discardVoiceClip(); - toast('Voice clip was too short', 'error'); - return; - } - state.voice.blob = blob; - state.voice.duration = duration; - if (voicePreview) { - if (voicePreview.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl); - const url = URL.createObjectURL(blob); - voicePreview.src = url; - voicePreview.dataset.objectUrl = url; - } - if (voiceReview) voiceReview.hidden = false; - if (voiceStatus) voiceStatus.textContent = 'READY · ' + formatDuration(duration); - } - - function discardVoiceClip() { - if (state.voice.recorder || state.voice.fallback) stopVoiceCapture(true); - if (voicePreview?.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl); - if (voicePreview) { - voicePreview.removeAttribute('src'); - delete voicePreview.dataset.objectUrl; - voicePreview.load(); - } - state.voice.blob = null; - state.voice.duration = 0; - if (voiceReview) voiceReview.hidden = true; - if (voiceStatus) voiceStatus.textContent = - 'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's'; - } - - async function sendVoiceClip() { - if (!state.voice.blob || voiceSendBtn?.disabled) return; - const maxBytes = state.config?.voice?.max_upload_bytes || 12582912; - if (state.voice.blob.size > maxBytes) { - toast('Voice clip exceeds the upload limit', 'error'); - return; - } - voiceSendBtn.disabled = true; - const fd = new FormData(); - fd.append('action', 'send_voice'); - fd.append('duration', state.voice.duration.toFixed(2)); - fd.append('voice', state.voice.blob, state.voice.blob.type.includes('wav') ? 'voice.wav' : 'voice.webm'); - try { - const res = await apiFetch('messages.php', { method: 'POST', body: fd }); - if (res.error) { - toast(res.error, 'error'); - } else if (res.success && res.message) { - if (!root.querySelector('[data-id="' + res.message.id + '"]')) appendMsg(res.message, true); - state.chat.lastId = Math.max(state.chat.lastId, res.message.id); - discardVoiceClip(); - } - } catch (e) { - toast('Voice send failed: ' + (e.message || 'check server'), 'error'); - } - if (voiceSendBtn) voiceSendBtn.disabled = false; - } - - function encodeWav(chunks, sampleRate) { - const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const buffer = new ArrayBuffer(44 + length * 2); - const view = new DataView(buffer); - const write = (offset, text) => { - for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i)); - }; - write(0, 'RIFF'); view.setUint32(4, 36 + length * 2, true); write(8, 'WAVE'); - write(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); - view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); - view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); - view.setUint16(34, 16, true); write(36, 'data'); view.setUint32(40, length * 2, true); - let offset = 44; - chunks.forEach(chunk => chunk.forEach(sample => { - const value = Math.max(-1, Math.min(1, sample)); - view.setInt16(offset, value < 0 ? value * 0x8000 : value * 0x7fff, true); - offset += 2; - })); - return new Blob([view], { type: 'audio/wav' }); - } - - function formatDuration(seconds) { - const total = Math.max(0, Math.ceil(Number(seconds) || 0)); - return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0'); - } - - // Incoming voice clips use one FIFO player so multiple polls never overlap. - function enqueueVoiceClip(audio) { - if (!audio || audio.dataset.voiceQueued === 'true') return; - audio.dataset.voiceQueued = 'true'; - state.voice.queue.push(audio); - audio.addEventListener('play', () => { - if (state.voice.current === audio) return; - const index = state.voice.queue.indexOf(audio); - if (index !== -1) state.voice.queue.splice(index, 1); - delete audio.dataset.voiceQueued; - updateVoiceQueueUI(); - }, { once: true }); - updateVoiceQueueUI(); - if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice(); - } - - async function playNextVoice() { - if (state.voice.current || !state.voice.queue.length) { - updateVoiceQueueUI(); - return; - } - - const audio = state.voice.queue.shift(); - if (!audio?.isConnected) { - delete audio?.dataset?.voiceQueued; - playNextVoice(); - return; - } - - state.voice.current = audio; - audio.closest('.cc-msg')?.classList.add('cc-msg-voice-playing'); - let finished = false; - const finish = () => { - if (finished) return; - finished = true; - audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing'); - delete audio.dataset.voiceQueued; - if (state.voice.current === audio) state.voice.current = null; - updateVoiceQueueUI(); - if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice(); - }; - audio.addEventListener('ended', finish, { once: true }); - audio.addEventListener('error', finish, { once: true }); - updateVoiceQueueUI(); - - try { - await audio.play(); - } catch (e) { - audio.removeEventListener('ended', finish); - audio.removeEventListener('error', finish); - audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing'); - state.voice.current = null; - state.voice.queue.unshift(audio); - state.voice.playbackBlocked = true; - updateVoiceQueueUI(); - } - } - - function clearVoiceQueue() { - if (state.voice.current) { - state.voice.current.pause(); - state.voice.current.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing'); - delete state.voice.current.dataset.voiceQueued; - } - state.voice.queue.forEach(audio => delete audio.dataset.voiceQueued); - state.voice.queue = []; - state.voice.current = null; - state.voice.playbackBlocked = false; - updateVoiceQueueUI(); - } - - function updateVoiceQueueUI() { - const waiting = state.voice.queue.length; - const total = waiting + (state.voice.current ? 1 : 0); - if (voiceQueueCount) voiceQueueCount.textContent = String(total); - if (!voiceQueueBtn) return; - voiceQueueBtn.disabled = total === 0; - voiceQueueBtn.classList.toggle('playing', !!state.voice.current); - voiceQueueBtn.classList.toggle('blocked', state.voice.playbackBlocked); - const label = state.voice.current - ? 'PLAYING' - : state.voice.playbackBlocked - ? 'TAP TO PLAY' - : 'PLAY NEXT'; - voiceQueueBtn.firstChild.textContent = label + ' '; - } - - // ─── Send ───────────────────────────────────────────────────────────────── - async function doSend() { - const msg = msgInput?.value?.trim(); - if (!msg || sendBtn?.disabled) return; - const max = state.config?.chat?.max_message_length || 500; - if (msg.length > max) { toast('Message too long (max ' + max + ' chars)', 'error'); return; } - - msgInput.value = ''; - updateCharCount(); - sendBtn.disabled = true; - - const fd = new FormData(); - fd.append('action','send'); - fd.append('message', msg); - - try { - const res = await apiFetch('messages.php', { method:'POST', body:fd }); - if (res.error) { - toast(res.error, 'error'); - msgInput.value = msg; - updateCharCount(); - } else if (res.success && res.message) { - if (!root.querySelector('[data-id="' + res.message.id + '"]')) { - appendMsg(res.message, true); - state.chat.lastId = Math.max(state.chat.lastId, res.message.id); - } - } - } catch(e) { - toast('Send failed: ' + (e.message || 'check server'), 'error'); - msgInput.value = msg; - updateCharCount(); - } - - sendBtn.disabled = false; - msgInput?.focus(); - } - - // ─── Polling ───────────────────────────────────────────────────────────── - function startPolling() { - stopPolling(); - doPoll(); - const iv = state.config?.chat?.poll_interval_ms || 2000; - state.chat.pollTimer = setInterval(doPoll, iv); - } - - function stopPolling() { - if (state.chat.pollTimer) { - clearInterval(state.chat.pollTimer); - state.chat.pollTimer = null; - } - state.chat.polling = false; - } - - async function doPoll() { - if (state.chat.polling) return; - state.chat.polling = true; - try { - const since = state.chat.lastId; - const data = await apiFetch('messages.php?action=poll&since=' + since); - - if (data.error === 'Not authenticated' || data.error === 'Session expired') { - stopPolling(); - state.auth.authenticated = false; - toast('Session expired — please login again', 'error'); - showAuth('login'); - state.chat.polling = false; - return; - } - - setConn('online'); - - if (Array.isArray(data.messages)) { - if (since === 0) { - renderMsgList(data.messages); - } else if (data.messages.length > 0) { - data.messages.forEach(m => appendMsg(m, true)); - state.chat.lastId = data.messages[data.messages.length - 1].id; - } - } - - if (data.online !== undefined && onlineBadge) { - onlineBadge.textContent = data.online; - } - - } catch(e) { - setConn('offline'); - } - state.chat.polling = false; - } - - // ─── Message rendering ──────────────────────────────────────────────────── - function renderMsgList(msgs) { - if (!msgList) return; - if (!msgs.length) { - msgList.innerHTML = '
// NO MESSAGES TODAY — BE THE FIRST TO TRANSMIT
'; - return; - } - msgList.innerHTML = ''; - msgs.forEach(m => appendMsg(m, false)); - scrollToBottom(true); - if (msgs.length) state.chat.lastId = msgs[msgs.length - 1].id; - } - - function appendMsg(msg, animate) { - if (!msgList) return; - - // Remove loading placeholder if present - const loader = msgList.querySelector('.cc-loading-msg, .cc-empty-day'); - if (loader) loader.remove(); - - const isOwn = msg.username === state.auth.username; - const div = document.createElement('div'); - div.className = 'cc-msg' + (isOwn ? ' cc-msg-own' : '') + (animate ? ' cc-msg-new' : ''); - div.dataset.id = msg.id; - - const isVoice = msg.message_type === 'voice' && msg.voice_url; - const safeText = isVoice - ? 'VOICE ' + - formatDuration(msg.voice_duration) + '' - : linkify(esc(msg.message)); - - div.innerHTML = - '' + fmtTime(msg.created_at) + '' + - '' + esc(msg.username) + '' + - '' + - '' + safeText + ''; - - msgList.appendChild(div); - if (isVoice && animate && !isOwn) enqueueVoiceClip(div.querySelector('audio')); - if (state.ui.atBottom) scrollToBottom(false); - } - - function scrollToBottom(instant) { - if (!msgList) return; - if (instant) { msgList.scrollTop = msgList.scrollHeight; } - else { msgList.scrollTo({ top: msgList.scrollHeight, behavior: 'smooth' }); } - state.ui.atBottom = true; - scrollBtn?.classList.remove('visible'); - } - - // ─── Global events ──────────────────────────────────────────────────────── - function bindGlobal() { - // Theme toggle - $('#cc-theme-btn')?.addEventListener('click', () => { - state.ui.darkMode = !state.ui.darkMode; - document.body.classList.toggle('cc-light', !state.ui.darkMode); - const icon = $('#cc-theme-icon'); - if (icon) icon.textContent = state.ui.darkMode ? '☀' : '☾'; - saveTheme(state.ui.darkMode); - }); - const themeIcon = $('#cc-theme-icon'); - if (themeIcon) themeIcon.textContent = state.ui.darkMode ? '☀' : '☾'; - } - - // ─── Conn status ───────────────────────────────────────────────────────── - function setConn(status) { - if (!connStatus) return; - connStatus.className = 'cc-conn-dot ' + status; - } - - // ─── Public ─────────────────────────────────────────────────────────────── - window.CyberChat = { init }; - -})(window, document); diff --git a/bootstrap.php b/bootstrap.php index 7480b56..7a49c70 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -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 . '%'; } diff --git a/embed-example.html b/embed-example.html index 66f8108..c7389fe 100644 --- a/embed-example.html +++ b/embed-example.html @@ -6,7 +6,7 @@ CyberChat — Embed Example - +