From 2881a3b338da0eedc3e59e90fe570854d6e2a847 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 8 Jun 2026 16:21:33 -0400 Subject: [PATCH] - Groups are now globally configurable under Admin > Plans & Platform > Group Availability. When disabled: Group navigation and channel selector are hidden. Group API operations are blocked. Active sessions update automatically. Existing groups and messages remain preserved. Static and browser tests passed with no console errors. --- README.md | 4 ++- api/config.php | 1 + api/groups.php | 13 ++++++- api/messages.php | 6 +++- assets/js/cyberchat-v4.js | 72 +++++++++++++++++++++++++++++++++++---- bootstrap.php | 4 +++ config.json | 3 ++ embed-example.html | 8 ++--- index.html | 4 +-- lib/admin_platform.php | 5 +++ 10 files changed, 104 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b94924b..a8b95bb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio - Stripe Checkout, Billing Portal, webhook synchronization, and cancellation - Configurable plans, prices, Stripe Price IDs, limits, and feature assignments - Subscription display switch that hides new sales while preserving billing management for existing customers -- Private groups with plan-based member limits, including the owner +- Globally configurable private groups with plan-based member limits, including the owner - Premium-configurable recording presence for both sending and viewing - Premium-configurable automatic voice sending - Tier-configurable automatic playback of newly received voice clips @@ -77,6 +77,8 @@ Each tier can independently configure its price, currency, billing interval, Str Administrators can assign a tier directly from **Admin > Users > Edit > Tier Override**. The override grants that tier's features without changing Stripe billing data and remains in effect until it is changed back to **Follow Stripe / billing status**. +Private groups can be enabled or disabled globally under **Admin > Plans & Platform > Group Availability**. Disabling groups hides their navigation and channels and blocks group API access without deleting existing groups or messages. + ## Mailgun Configure these fields under **Admin > Plans & Platform**: diff --git a/api/config.php b/api/config.php index cd665ef..6d5f2bc 100644 --- a/api/config.php +++ b/api/config.php @@ -19,5 +19,6 @@ jsonResponse([ 'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912, 'auto_play_default' => $config['voice']['auto_play_default'] ?? true, ], + 'groups' => ['enabled' => $config['groups']['enabled'] ?? true], 'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true], ]); diff --git a/api/groups.php b/api/groups.php index 8e89c77..04bfdf3 100644 --- a/api/groups.php +++ b/api/groups.php @@ -6,9 +6,20 @@ sendCorsHeaders(); $user = authRequired(); $action = $_POST['action'] ?? $_GET['action'] ?? 'list'; +if (!groupsEnabled()) { + if ($action === 'list') { + jsonResponse(['groups' => [], 'member_limit' => 0, 'enabled' => false]); + } + jsonResponse(['error' => 'Private groups are currently disabled', 'groups_enabled' => false], 403); +} + switch ($action) { case 'list': - jsonResponse(['groups' => userGroups((int)$user['id']), 'member_limit' => (int)featureValue($user, 'group_member_limit', 2)]); + jsonResponse([ + 'groups' => userGroups((int)$user['id']), + 'member_limit' => (int)featureValue($user, 'group_member_limit', 2), + 'enabled' => true, + ]); case 'create': $name = trim((string)($_POST['name'] ?? '')); diff --git a/api/messages.php b/api/messages.php index 2f57490..48766ab 100644 --- a/api/messages.php +++ b/api/messages.php @@ -33,6 +33,9 @@ function messagePayload(array $row): array { function requestedGroup(array $user): ?int { $groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null); + if ($groupId !== null && !groupsEnabled()) { + jsonResponse(['error' => 'Private groups are currently disabled', 'groups_enabled' => false], 403); + } requireGroupAccess($user, $groupId); return $groupId; } @@ -171,7 +174,8 @@ function pollMessages(): never { 'messages' => array_map('messagePayload', $rows), 'online' => (int)$onlineStmt->fetchColumn(), 'recording' => $recording, - 'groups' => userGroups((int)$user['id']), + 'groups' => groupsEnabled() ? userGroups((int)$user['id']) : [], + 'groups_enabled' => groupsEnabled(), 'group_id' => $groupId, 'server_time' => time(), ]); diff --git a/assets/js/cyberchat-v4.js b/assets/js/cyberchat-v4.js index e2b8608..9ee01fa 100644 --- a/assets/js/cyberchat-v4.js +++ b/assets/js/cyberchat-v4.js @@ -46,6 +46,29 @@ return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key) ? state.user.features[key] : fallback; } + function groupsAvailable() { + return state.config.groups?.enabled !== false; + } + function applyGroupsAvailability(enabled, groups = []) { + const next = enabled !== false; + const changed = groupsAvailable() !== next; + state.config.groups = { ...(state.config.groups || {}), enabled: next }; + if (next) { + if (changed) syncGroups(groups); + } else { + state.groups = []; + state.groupSeen = {}; + state.groupUnread = {}; + state.groupId = null; + if (state.view === 'groups') state.view = 'chat'; + } + return changed; + } + function disableGroups(message = '') { + applyGroupsAvailability(false); + if (message) toast(message); + app(); + } function toast(message, type = 'error') { const area = $('#cc-toasts'); if (!area) return; @@ -238,10 +261,16 @@ async function enter() { const [groups, billing] = await Promise.all([ - api('groups.php', null, 'action=list').catch(() => ({ groups: [] })), + groupsAvailable() + ? api('groups.php', null, 'action=list').catch(() => ({ groups: [] })) + : Promise.resolve({ groups: [], enabled: false }), api('billing.php', null, 'action=status').catch(() => null) ]); - state.groups = groups.groups || []; + state.config.groups = { + ...(state.config.groups || {}), + enabled: groups.enabled !== false && groupsAvailable(), + }; + state.groups = groupsAvailable() ? (groups.groups || []) : []; state.groupSeen = {}; state.groupUnread = {}; state.groups.forEach(group => { state.groupSeen[group.id] = Number(group.latest_message_id || 0); }); @@ -266,8 +295,9 @@ } } function app() { + if (!groupsAvailable() && state.view === 'groups') state.view = 'chat'; $('#cc-body').innerHTML = `
`; $$('[data-view]').forEach(button => button.addEventListener('click', () => { @@ -281,7 +311,7 @@ stopVoice(true); $$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view)); if (state.view === 'chat') chat(); - else if (state.view === 'groups') groupsView(); + else if (state.view === 'groups' && groupsAvailable()) groupsView(); else if (state.view === 'archive') archiveView(); else accountView(); } @@ -311,7 +341,7 @@ $('#cc-view').innerHTML = `
YOU:${esc(state.user.username)} ${esc(state.user.plan.name)}
-
+ ${groupsAvailable() ? `` : ''}
LOADING CHANNEL
${voice ? `
@@ -322,7 +352,7 @@
` : ''}
${max}
`; - $('#group-select').addEventListener('change', event => { + $('#group-select')?.addEventListener('change', event => { state.groupId = event.target.value ? Number(event.target.value) : null; if (state.groupId !== null) { state.groupUnread[state.groupId] = false; @@ -355,6 +385,10 @@ if (!message) return; input.value = ''; const result = await api('messages.php', form({ action: 'send', message, group_id: state.groupId || '' })); + if (result.groups_enabled === false) { + input.value = message; + return disableGroups(result.error); + } if (result.error) { input.value = message; return toast(result.error); } appendMessage(result.message); state.lastId = Math.max(state.lastId, result.message.id); @@ -381,9 +415,15 @@ const result = await api('messages.php', null, query.toString()).catch(() => null); if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return; if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) { + const availabilityChanged = applyGroupsAvailability(result.groups_enabled !== false, result.groups || []); + if (availabilityChanged) { + toast(groupsAvailable() ? 'Private groups are now available' : 'Private groups have been disabled', 'success'); + app(); + return; + } $('#cc-conn-dot').className = 'cc-conn-dot online'; $('#cc-online-count').textContent = result.online; - syncGroups(result.groups, true); + if (groupsAvailable()) syncGroups(result.groups, true); if (requestedSince === 0) renderMessages(result.messages || []); else (result.messages || []).forEach(message => appendMessage(message, true)); if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id; @@ -408,6 +448,10 @@ async function recoverGroupAccess(groupId, generation) { const result = await api('groups.php', null, 'action=list').catch(() => null); if (!result || result.error || generation !== state.pollGeneration) return; + if (result.enabled === false) { + disableGroups('Private groups have been disabled'); + return; + } syncGroups(result.groups || []); if (!state.groups.some(group => Number(group.id) === Number(groupId))) { state.groupId = null; @@ -560,6 +604,7 @@ body.append('voice', state.voice.blob, 'voice.webm'); const result = await api('messages.php', { method: 'POST', body }); state.voice.sending = false; + if (result.groups_enabled === false) return disableGroups(result.error); if (result.error) return toast(result.error); appendMessage(result.message); state.lastId = Math.max(state.lastId, result.message.id); @@ -567,6 +612,11 @@ } function groupsView() { + if (!groupsAvailable()) { + state.view = 'chat'; + renderView(); + return; + } const limit = Number(feature('group_member_limit', 2)); $('#cc-view').innerHTML = `

GROUPS

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

@@ -592,6 +642,10 @@ })); api('groups.php', null, 'action=list').then(result => { if (result.error || state.view !== 'groups') return; + if (result.enabled === false) { + disableGroups('Private groups have been disabled'); + return; + } const before = JSON.stringify(state.groups); syncGroups(result.groups || []); if (JSON.stringify(state.groups) !== before) groupsView(); @@ -608,18 +662,21 @@ } async function createGroup() { const result = await api('groups.php', form({ action: 'create', name: $('#group-name').value.trim(), members: $('#group-members').value.trim() })); + if (result.groups_enabled === false) return disableGroups(result.error); if (result.error) return toast(result.error); state.groups = result.groups; groupsView(); } async function addMember(groupId) { const result = await api('groups.php', form({ action: 'add_member', group_id: groupId, username: $('#add-' + groupId).value.trim() })); + if (result.groups_enabled === false) return disableGroups(result.error); if (result.error) return toast(result.error); state.groups = result.groups; groupsView(); } async function removeMember(groupId, userId) { const result = await api('groups.php', form({ action: 'remove_member', group_id: groupId, user_id: userId })); + if (result.groups_enabled === false) return disableGroups(result.error); if (result.error) return toast(result.error); state.groups = result.groups; groupsView(); @@ -627,6 +684,7 @@ async function deleteGroup(groupId) { if (!confirm('Delete this group and its messages?')) return; const result = await api('groups.php', form({ action: 'delete', group_id: groupId })); + if (result.groups_enabled === false) return disableGroups(result.error); if (result.error) return toast(result.error); state.groups = result.groups; if (state.groupId === groupId) state.groupId = null; diff --git a/bootstrap.php b/bootstrap.php index 2a9f3aa..c1dbfbe 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -69,6 +69,10 @@ function getConfigVal(string $path, mixed $default = null): mixed { return $value; } +function groupsEnabled(): bool { + return (bool)getConfigVal('groups.enabled', true); +} + function setConfigValues(array $changes): void { $config = getConfig(); foreach ($changes as $path => $value) { diff --git a/config.json b/config.json index cd509ac..3c774da 100644 --- a/config.json +++ b/config.json @@ -23,6 +23,9 @@ "auto_play_default": true, "upload_dir": "uploads/voice" }, + "groups": { + "enabled": true + }, "database": { "main_db": "db/chat.sqlite", "archive_path": "archive/{year}/{month}/{day}.sqlite", diff --git a/embed-example.html b/embed-example.html index 7e236c9..68f7679 100644 --- a/embed-example.html +++ b/embed-example.html @@ -6,7 +6,7 @@ CyberChat — Embed Example - +