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 - +