- Implemented.
Replaced polling with a single queued cursor worker, timeout recovery, backoff, deduplication, chronological insertion, and capped DOM history in cyberchat-v4.js. Removed Groups UI, API, configuration, tier features, admin controls, and archive exposure. Legacy group records remain stored but inaccessible. Added configurable polling limits in config.json.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# CyberChat
|
# CyberChat
|
||||||
|
|
||||||
CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with configurable paid tiers, Stripe subscriptions, Mailgun email verification, private groups, voice messages, personal archives, an administrator dashboard, and an optional MySQL/MariaDB mirror.
|
CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with configurable paid tiers, Stripe subscriptions, Mailgun email verification, voice messages, personal archives, an administrator dashboard, and an optional MySQL/MariaDB mirror.
|
||||||
|
|
||||||
## Included Features
|
## Included Features
|
||||||
|
|
||||||
@@ -10,12 +10,11 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio
|
|||||||
- Stripe Checkout, Billing Portal, webhook synchronization, and cancellation
|
- Stripe Checkout, Billing Portal, webhook synchronization, and cancellation
|
||||||
- Configurable plans, prices, Stripe Price IDs, limits, and feature assignments
|
- Configurable plans, prices, Stripe Price IDs, limits, and feature assignments
|
||||||
- Subscription display switch that hides new sales while preserving billing management for existing customers
|
- Subscription display switch that hides new sales while preserving billing management for existing customers
|
||||||
- Globally configurable private groups with plan-based member limits, including the owner
|
|
||||||
- Premium-configurable recording presence for both sending and viewing
|
- Premium-configurable recording presence for both sending and viewing
|
||||||
- Premium-configurable automatic voice sending
|
- Premium-configurable automatic voice sending
|
||||||
- Tier-configurable automatic playback of newly received voice clips
|
- Tier-configurable automatic playback of newly received voice clips
|
||||||
- Administrator tier overrides that grant plan features without payment
|
- Administrator tier overrides that grant plan features without payment
|
||||||
- Resilient polling with stalled-request timeouts and post-send reconciliation
|
- Single-worker queued polling with cursor catch-up, request timeouts, deduplication, bounded retry backoff, and a capped browser message buffer
|
||||||
- Plan-based custom username colors
|
- Plan-based custom username colors
|
||||||
- Personal text archives retained for at least 30 days
|
- Personal text archives retained for at least 30 days
|
||||||
- Plan-based voice archive access and JSON/CSV exports
|
- Plan-based voice archive access and JSON/CSV exports
|
||||||
@@ -64,8 +63,6 @@ The defaults are starting points and are fully editable in the dashboard.
|
|||||||
|
|
||||||
| Feature | Free | Plus | Pro |
|
| Feature | Free | Plus | Pro |
|
||||||
|---|---:|---:|---:|
|
|---|---:|---:|---:|
|
||||||
| Groups available | Yes | Yes | Yes |
|
|
||||||
| Group members, including owner | 2 | 4 | 8 |
|
|
||||||
| Recording status send/view | No | Yes | Yes |
|
| Recording status send/view | No | Yes | Yes |
|
||||||
| Automatic voice send | No | Yes | Yes |
|
| Automatic voice send | No | Yes | Yes |
|
||||||
| Automatic voice play | No | Yes | Yes |
|
| Automatic voice play | No | Yes | Yes |
|
||||||
@@ -75,12 +72,8 @@ The defaults are starting points and are fully editable in the dashboard.
|
|||||||
| Voice archive/export | No | Yes | Yes |
|
| Voice archive/export | No | Yes | Yes |
|
||||||
| Email 2FA | No | Yes | Yes |
|
| Email 2FA | No | Yes | Yes |
|
||||||
|
|
||||||
Each tier has an explicit **Enable groups for this tier** toggle and a separate **Maximum People Per Group** value. The minimum is two people, including the group owner; disabling groups uses the toggle rather than a numeric value of zero.
|
|
||||||
|
|
||||||
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**.
|
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** or **Admin > Config > Feature Availability**. Each tier also has a **Groups Available** entitlement. Disabling either control hides group navigation and channels and blocks group API access without deleting existing groups or messages.
|
|
||||||
|
|
||||||
## Mailgun
|
## Mailgun
|
||||||
|
|
||||||
Configure these fields under **Admin > Plans & Platform**:
|
Configure these fields under **Admin > Plans & Platform**:
|
||||||
@@ -168,7 +161,6 @@ api/account.php Email, color, and 2FA settings
|
|||||||
api/archive.php Private personal archive and export
|
api/archive.php Private personal archive and export
|
||||||
api/auth.php Registration, login, 2FA, sessions
|
api/auth.php Registration, login, 2FA, sessions
|
||||||
api/billing.php Stripe checkout, portal, and cancellation
|
api/billing.php Stripe checkout, portal, and cancellation
|
||||||
api/groups.php Private group management
|
|
||||||
api/messages.php Text, voice, polling, and recording presence
|
api/messages.php Text, voice, polling, and recording presence
|
||||||
api/stripe-webhook.php Stripe event synchronization
|
api/stripe-webhook.php Stripe event synchronization
|
||||||
assets/js/cyberchat-v4.js Browser application
|
assets/js/cyberchat-v4.js Browser application
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ function ensureAdminMessageColumns(PDO $d): void {
|
|||||||
$columns = [];
|
$columns = [];
|
||||||
foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
|
foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
|
||||||
foreach ([
|
foreach ([
|
||||||
'group_id' => 'INTEGER',
|
|
||||||
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||||||
'voice_file' => 'TEXT',
|
'voice_file' => 'TEXT',
|
||||||
'voice_mime' => 'TEXT',
|
'voice_mime' => 'TEXT',
|
||||||
@@ -53,6 +52,11 @@ function ensureAdminMessageColumns(PDO $d): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adminPublicMessagePredicate(PDO $d, string $alias = ''): string {
|
||||||
|
$prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
|
||||||
|
return isset(tableColumns($d, 'messages')['group_id']) ? "{$prefix}group_id IS NULL" : '1=1';
|
||||||
|
}
|
||||||
|
|
||||||
function archiveDB(string $year, string $month, string $day): ?PDO {
|
function archiveDB(string $year, string $month, string $day): ?PDO {
|
||||||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
||||||
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
|
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
|
||||||
@@ -76,7 +80,6 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
|
|||||||
$a->exec("CREATE TABLE IF NOT EXISTS messages (
|
$a->exec("CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
group_id INTEGER,
|
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
color TEXT NOT NULL,
|
color TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
@@ -152,7 +155,9 @@ function refreshArchiveMeta(PDO $archive): void {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT
|
value TEXT
|
||||||
)");
|
)");
|
||||||
$count = (int)$archive->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
$count = (int)$archive->query(
|
||||||
|
"SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($archive)
|
||||||
|
)->fetchColumn();
|
||||||
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)");
|
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)");
|
||||||
$meta->execute(['archived_at', (string)time()]);
|
$meta->execute(['archived_at', (string)time()]);
|
||||||
$meta->execute(['message_count', (string)$count]);
|
$meta->execute(['message_count', (string)$count]);
|
||||||
@@ -180,22 +185,22 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
csrfCheck();
|
csrfCheck();
|
||||||
$act = $_POST['action'] ?? '';
|
$act = $_POST['action'] ?? '';
|
||||||
|
|
||||||
if (in_array($act, ['save_group_availability','save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
|
if (in_array($act, ['save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) {
|
||||||
try {
|
try {
|
||||||
$message = handleAdminPlatformAction($act);
|
$message = handleAdminPlatformAction($act);
|
||||||
flash($message ?: 'Platform action completed.');
|
flash($message ?: 'Platform action completed.');
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
flash($e->getMessage(), 'err');
|
flash($e->getMessage(), 'err');
|
||||||
}
|
}
|
||||||
$returnTab = $act === 'save_group_availability' && ($_POST['return_tab'] ?? '') === 'config'
|
redirect('tab=platform');
|
||||||
? 'config' : 'platform';
|
|
||||||
redirect('tab=' . $returnTab);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Messages ──────────────────────────────────────────────────────────────
|
// ── Messages ──────────────────────────────────────────────────────────────
|
||||||
if ($act === 'archive_voice_clip') {
|
if ($act === 'archive_voice_clip') {
|
||||||
$id = (int)($_POST['msg_id'] ?? 0);
|
$id = (int)($_POST['msg_id'] ?? 0);
|
||||||
$stmt = db()->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
|
$live = db();
|
||||||
|
$stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice' AND "
|
||||||
|
. adminPublicMessagePredicate($live));
|
||||||
$stmt->execute([$id]);
|
$stmt->execute([$id]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
@@ -222,10 +227,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
if ($existingFile === false) {
|
if ($existingFile === false) {
|
||||||
$insert = $adb->prepare("INSERT INTO messages
|
$insert = $adb->prepare("INSERT INTO messages
|
||||||
(id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
$insert->execute([
|
$insert->execute([
|
||||||
$row['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'], $row['message'],
|
$row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'],
|
||||||
$row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'],
|
$row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'],
|
||||||
$row['created_at'], $row['day_key']
|
$row['created_at'], $row['day_key']
|
||||||
]);
|
]);
|
||||||
@@ -236,7 +241,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if ($adb->inTransaction()) $adb->rollBack();
|
if ($adb->inTransaction()) $adb->rollBack();
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
$delete = db()->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
|
$delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice' AND "
|
||||||
|
. adminPublicMessagePredicate($live));
|
||||||
$delete->execute([$id]);
|
$delete->execute([$id]);
|
||||||
if ($delete->rowCount() !== 1) {
|
if ($delete->rowCount() !== 1) {
|
||||||
throw new RuntimeException('The live clip changed before it could be removed.');
|
throw new RuntimeException('The live clip changed before it could be removed.');
|
||||||
@@ -252,10 +258,12 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$id = (int)$_POST['msg_id'];
|
$id = (int)$_POST['msg_id'];
|
||||||
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
|
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
|
||||||
if ($src === 'live') {
|
if ($src === 'live') {
|
||||||
$row = db()->prepare("SELECT voice_file FROM messages WHERE id = ?");
|
$live = db();
|
||||||
|
$public = adminPublicMessagePredicate($live);
|
||||||
|
$row = $live->prepare("SELECT voice_file FROM messages WHERE id = ? AND $public");
|
||||||
$row->execute([$id]);
|
$row->execute([$id]);
|
||||||
deleteRecordingsForRows($row->fetchAll());
|
deleteRecordingsForRows($row->fetchAll());
|
||||||
db()->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
|
$live->prepare("DELETE FROM messages WHERE id = ? AND $public")->execute([$id]);
|
||||||
flash("Message #$id deleted.");
|
flash("Message #$id deleted.");
|
||||||
} else {
|
} else {
|
||||||
[$_, $day] = explode(':', $src, 2);
|
[$_, $day] = explode(':', $src, 2);
|
||||||
@@ -286,7 +294,9 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
flash('Displayed sender is too long.', 'err'); redirect($_POST['return_qs'] ?? '');
|
flash('Displayed sender is too long.', 'err'); redirect($_POST['return_qs'] ?? '');
|
||||||
}
|
}
|
||||||
if ($src === 'live') {
|
if ($src === 'live') {
|
||||||
db()->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")->execute([$txt, $username, $id]);
|
$live = db();
|
||||||
|
$live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ? AND "
|
||||||
|
. adminPublicMessagePredicate($live))->execute([$txt, $username, $id]);
|
||||||
flash("Message #$id updated.");
|
flash("Message #$id updated.");
|
||||||
} else {
|
} else {
|
||||||
[$_, $day] = explode(':', $src, 2);
|
[$_, $day] = explode(':', $src, 2);
|
||||||
@@ -303,11 +313,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if ($act === 'delete_messages_bulk') {
|
if ($act === 'delete_messages_bulk') {
|
||||||
$ids = array_map('intval', $_POST['msg_ids'] ?? []);
|
$ids = array_map('intval', $_POST['msg_ids'] ?? []);
|
||||||
if ($ids) {
|
if ($ids) {
|
||||||
|
$live = db();
|
||||||
|
$public = adminPublicMessagePredicate($live);
|
||||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||||
$rows = db()->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)");
|
$rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph) AND $public");
|
||||||
$rows->execute($ids);
|
$rows->execute($ids);
|
||||||
deleteRecordingsForRows($rows->fetchAll());
|
deleteRecordingsForRows($rows->fetchAll());
|
||||||
db()->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
|
$live->prepare("DELETE FROM messages WHERE id IN ($ph) AND $public")->execute($ids);
|
||||||
flash(count($ids) . ' message(s) deleted.');
|
flash(count($ids) . ' message(s) deleted.');
|
||||||
}
|
}
|
||||||
redirect($_POST['return_qs'] ?? '');
|
redirect($_POST['return_qs'] ?? '');
|
||||||
@@ -315,10 +327,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if ($act === 'delete_day_live') {
|
if ($act === 'delete_day_live') {
|
||||||
$day = $_POST['day_key'];
|
$day = $_POST['day_key'];
|
||||||
$rows = db()->prepare("SELECT voice_file FROM messages WHERE day_key = ?");
|
$live = db();
|
||||||
|
$public = adminPublicMessagePredicate($live);
|
||||||
|
$rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ? AND $public");
|
||||||
$rows->execute([$day]);
|
$rows->execute([$day]);
|
||||||
deleteRecordingsForRows($rows->fetchAll());
|
deleteRecordingsForRows($rows->fetchAll());
|
||||||
$st = db()->prepare("DELETE FROM messages WHERE day_key = ?"); $st->execute([$day]);
|
$st = $live->prepare("DELETE FROM messages WHERE day_key = ? AND $public");
|
||||||
|
$st->execute([$day]);
|
||||||
flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
|
flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
|
||||||
redirect('tab=messages');
|
redirect('tab=messages');
|
||||||
}
|
}
|
||||||
@@ -340,8 +355,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($act === 'clear_all_live') {
|
if ($act === 'clear_all_live') {
|
||||||
deleteRecordingsForRows(db()->query("SELECT voice_file FROM messages")->fetchAll());
|
$live = db();
|
||||||
db()->exec("DELETE FROM messages");
|
$public = adminPublicMessagePredicate($live);
|
||||||
|
deleteRecordingsForRows($live->query("SELECT voice_file FROM messages WHERE $public")->fetchAll());
|
||||||
|
$live->exec("DELETE FROM messages WHERE $public");
|
||||||
flash("All live messages cleared.");
|
flash("All live messages cleared.");
|
||||||
redirect('tab=messages');
|
redirect('tab=messages');
|
||||||
}
|
}
|
||||||
@@ -456,11 +473,13 @@ $tab = $_GET['tab'] ?? 'dashboard';
|
|||||||
$stats = [];
|
$stats = [];
|
||||||
if ($isLoggedIn) {
|
if ($isLoggedIn) {
|
||||||
try {
|
try {
|
||||||
$stats['users'] = db()->query("SELECT COUNT(*) FROM users")->fetchColumn();
|
$live = db();
|
||||||
$stats['messages'] = db()->query("SELECT COUNT(*) FROM messages")->fetchColumn();
|
$public = adminPublicMessagePredicate($live);
|
||||||
$stats['voice'] = db()->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn();
|
$stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn();
|
||||||
$stats['sessions'] = db()->query("SELECT COUNT(*) FROM sessions")->fetchColumn();
|
$stats['messages'] = $live->query("SELECT COUNT(*) FROM messages WHERE $public")->fetchColumn();
|
||||||
$stats['today'] = db()->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->fetchColumn();
|
$stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice' AND $public")->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();
|
||||||
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
|
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
|
||||||
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
|
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
|
||||||
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
|
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
|
||||||
@@ -476,7 +495,10 @@ if ($isLoggedIn) {
|
|||||||
$day = "{$m[1]}-{$m[2]}-{$m[3]}";
|
$day = "{$m[1]}-{$m[2]}-{$m[3]}";
|
||||||
$sz = filesize($f);
|
$sz = filesize($f);
|
||||||
$cnt = 0;
|
$cnt = 0;
|
||||||
try { $x = new PDO('sqlite:'.$f); $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn(); } catch(Exception $e){}
|
try {
|
||||||
|
$x = new PDO('sqlite:'.$f);
|
||||||
|
$cnt = $x->query("SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($x))->fetchColumn();
|
||||||
|
} catch(Exception $e) {}
|
||||||
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
|
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,7 +967,9 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel-body np">
|
<div class="panel-body np">
|
||||||
<?php
|
<?php
|
||||||
$recent = db()->query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
|
$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();
|
||||||
if (empty($recent)): ?>
|
if (empty($recent)): ?>
|
||||||
<div class="empty">NO MESSAGES YET</div>
|
<div class="empty">NO MESSAGES YET</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -1023,16 +1047,18 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
$fDay = trim($_GET['fd'] ?? '');
|
$fDay = trim($_GET['fd'] ?? '');
|
||||||
$fType = trim($_GET['type'] ?? '');
|
$fType = trim($_GET['type'] ?? '');
|
||||||
|
|
||||||
$where = []; $params = [];
|
$live = db();
|
||||||
|
$where = [adminPublicMessagePredicate($live)]; $params = [];
|
||||||
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
|
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
|
||||||
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
|
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
|
||||||
if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; }
|
if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; }
|
||||||
if (in_array($fType, ['text','voice'], true)) { $where[] = "message_type = ?"; $params[] = $fType; }
|
if (in_array($fType, ['text','voice'], true)) { $where[] = "message_type = ?"; $params[] = $fType; }
|
||||||
$sql = "SELECT * FROM messages" . ($where ? " WHERE ".implode(" AND ", $where) : "") . " ORDER BY created_at DESC LIMIT 500";
|
$sql = "SELECT * FROM messages" . ($where ? " WHERE ".implode(" AND ", $where) : "") . " ORDER BY created_at DESC LIMIT 500";
|
||||||
$msgs = db()->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
|
$msgs = $live->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
|
||||||
|
|
||||||
// Days available
|
// Days available
|
||||||
$days = db()->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
|
$days = $live->query("SELECT DISTINCT day_key FROM messages WHERE "
|
||||||
|
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
@@ -1176,7 +1202,8 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
|
|
||||||
$voiceRows = [];
|
$voiceRows = [];
|
||||||
if ($vSource !== 'archive') {
|
if ($vSource !== 'archive') {
|
||||||
$vWhere = ["message_type = 'voice'"];
|
$live = db();
|
||||||
|
$vWhere = ["message_type = 'voice'", adminPublicMessagePredicate($live)];
|
||||||
$vParams = [];
|
$vParams = [];
|
||||||
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
||||||
if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; }
|
if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; }
|
||||||
@@ -1194,7 +1221,8 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
try {
|
try {
|
||||||
$vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']);
|
$vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']);
|
||||||
if (!$vAdb) continue;
|
if (!$vAdb) continue;
|
||||||
$vSql = "SELECT * FROM messages WHERE message_type = 'voice'";
|
$vSql = "SELECT * FROM messages WHERE message_type = 'voice' AND "
|
||||||
|
. adminPublicMessagePredicate($vAdb);
|
||||||
$vParams = [];
|
$vParams = [];
|
||||||
if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
|
||||||
$vSql .= ' ORDER BY created_at DESC LIMIT 500';
|
$vSql .= ' ORDER BY created_at DESC LIMIT 500';
|
||||||
@@ -1212,8 +1240,10 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
|
|
||||||
usort($voiceRows, fn($a, $b) => ((int)$b['created_at'] <=> (int)$a['created_at']));
|
usort($voiceRows, fn($a, $b) => ((int)$b['created_at'] <=> (int)$a['created_at']));
|
||||||
$voiceRows = array_slice($voiceRows, 0, 500);
|
$voiceRows = array_slice($voiceRows, 0, 500);
|
||||||
|
$live = db();
|
||||||
$voiceDays = array_unique(array_merge(
|
$voiceDays = array_unique(array_merge(
|
||||||
db()->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
|
$live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' AND "
|
||||||
|
. adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
|
||||||
array_column($archives, 'day')
|
array_column($archives, 'day')
|
||||||
));
|
));
|
||||||
rsort($voiceDays);
|
rsort($voiceDays);
|
||||||
@@ -1326,7 +1356,7 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
if ($adb) {
|
if ($adb) {
|
||||||
$fAUser = trim($_GET['au'] ?? '');
|
$fAUser = trim($_GET['au'] ?? '');
|
||||||
$fAText = trim($_GET['at'] ?? '');
|
$fAText = trim($_GET['at'] ?? '');
|
||||||
$awhere = []; $aparams = [];
|
$awhere = [adminPublicMessagePredicate($adb)]; $aparams = [];
|
||||||
if ($fAUser) { $awhere[] = "username LIKE ?"; $aparams[] = '%'.$fAUser.'%'; }
|
if ($fAUser) { $awhere[] = "username LIKE ?"; $aparams[] = '%'.$fAUser.'%'; }
|
||||||
if ($fAText) { $awhere[] = "message LIKE ?"; $aparams[] = '%'.$fAText.'%'; }
|
if ($fAText) { $awhere[] = "message LIKE ?"; $aparams[] = '%'.$fAText.'%'; }
|
||||||
$asql = "SELECT * FROM messages" . ($awhere ? " WHERE ".implode(" AND ", $awhere) : "") . " ORDER BY created_at DESC LIMIT 500";
|
$asql = "SELECT * FROM messages" . ($awhere ? " WHERE ".implode(" AND ", $awhere) : "") . " ORDER BY created_at DESC LIMIT 500";
|
||||||
@@ -1506,9 +1536,11 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$allUsers = db()->query("
|
$live = db();
|
||||||
|
$allUsers = $live->query("
|
||||||
SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name,
|
SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name,
|
||||||
(SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count,
|
(SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id AND "
|
||||||
|
. adminPublicMessagePredicate($live, 'm') . ") as msg_count,
|
||||||
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
|
(SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN plans p ON p.id=u.plan_id
|
LEFT JOIN plans p ON p.id=u.plan_id
|
||||||
@@ -1673,22 +1705,6 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
<p>// edit config.json live — changes take effect immediately</p>
|
<p>// edit config.json live — changes take effect immediately</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-head"><span class="panel-title">// FEATURE AVAILABILITY</span></div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
|
|
||||||
<input type="hidden" name="action" value="save_group_availability">
|
|
||||||
<input type="hidden" name="return_tab" value="config">
|
|
||||||
<label class="cb-row">
|
|
||||||
<input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>>
|
|
||||||
<span><strong>Groups Enabled</strong> — master switch for private group navigation, channels, and API access.</span>
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn btn-g">SAVE GROUP AVAILABILITY</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="panel-title">// config.json</span>
|
<span class="panel-title">// config.json</span>
|
||||||
@@ -1720,12 +1736,13 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
<tr><td class="mono">chat.max_username_length</td><td class="dim">integer</td><td>Max callsign length</td></tr>
|
<tr><td class="mono">chat.max_username_length</td><td class="dim">integer</td><td>Max callsign length</td></tr>
|
||||||
<tr><td class="mono">chat.poll_interval_ms</td><td class="dim">integer</td><td>Client poll interval (ms)</td></tr>
|
<tr><td class="mono">chat.poll_interval_ms</td><td class="dim">integer</td><td>Client poll interval (ms)</td></tr>
|
||||||
<tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr>
|
<tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr>
|
||||||
|
<tr><td class="mono">chat.poll_max_backoff_ms</td><td class="dim">integer</td><td>Maximum delay after repeated poll failures</td></tr>
|
||||||
|
<tr><td class="mono">chat.max_rendered_messages</td><td class="dim">integer</td><td>Maximum chat rows retained in the browser</td></tr>
|
||||||
<tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr>
|
<tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr>
|
||||||
<tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr>
|
<tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr>
|
||||||
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
|
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
|
||||||
<tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr>
|
<tr><td class="mono">voice.max_upload_bytes</td><td class="dim">integer</td><td>Maximum recording upload size</td></tr>
|
||||||
<tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr>
|
<tr><td class="mono">voice.auto_play_default</td><td class="dim">bool</td><td>Queue and play incoming clips sequentially by default</td></tr>
|
||||||
<tr><td class="mono">groups.enabled</td><td class="dim">bool</td><td>Global master switch for private groups</td></tr>
|
|
||||||
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
|
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
|
||||||
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
|
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
|
||||||
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (10–14)</td></tr>
|
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (10–14)</td></tr>
|
||||||
|
|||||||
+4
-4
@@ -14,7 +14,7 @@ if ($action === 'export') {
|
|||||||
$format = strtolower((string)($_GET['format'] ?? 'json'));
|
$format = strtolower((string)($_GET['format'] ?? 'json'));
|
||||||
$export = array_map(function(array $row) use ($canVoiceExport): array {
|
$export = array_map(function(array $row) use ($canVoiceExport): array {
|
||||||
$item = [
|
$item = [
|
||||||
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null,
|
'id' => (int)$row['id'],
|
||||||
'username' => $row['username'], 'message' => $row['message'],
|
'username' => $row['username'], 'message' => $row['message'],
|
||||||
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']),
|
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']),
|
||||||
];
|
];
|
||||||
@@ -28,9 +28,9 @@ if ($action === 'export') {
|
|||||||
header('Content-Type: text/csv; charset=utf-8');
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
|
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
|
||||||
$out = fopen('php://output', 'w');
|
$out = fopen('php://output', 'w');
|
||||||
fputcsv($out, ['id', 'group_id', 'username', 'message', 'type', 'created_at', 'voice_url']);
|
fputcsv($out, ['id', 'username', 'message', 'type', 'created_at', 'voice_url']);
|
||||||
foreach ($export as $row) fputcsv($out, [
|
foreach ($export as $row) fputcsv($out, [
|
||||||
$row['id'], $row['group_id'], $row['username'], $row['message'], $row['type'],
|
$row['id'], $row['username'], $row['message'], $row['type'],
|
||||||
$row['created_at'], $row['voice_url'] ?? '',
|
$row['created_at'], $row['voice_url'] ?? '',
|
||||||
]);
|
]);
|
||||||
fclose($out);
|
fclose($out);
|
||||||
@@ -44,7 +44,7 @@ if ($action === 'export') {
|
|||||||
|
|
||||||
$payload = array_map(function(array $row): array {
|
$payload = array_map(function(array $row): array {
|
||||||
return [
|
return [
|
||||||
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null,
|
'id' => (int)$row['id'],
|
||||||
'message' => $row['message'], 'message_type' => $row['message_type'],
|
'message' => $row['message'], 'message_type' => $row['message_type'],
|
||||||
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
|
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
|
||||||
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null,
|
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null,
|
||||||
|
|||||||
+2
-1
@@ -10,6 +10,8 @@ jsonResponse([
|
|||||||
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
|
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
|
||||||
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
|
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
|
||||||
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
|
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
|
||||||
|
'poll_max_backoff_ms' => $config['chat']['poll_max_backoff_ms'] ?? 15000,
|
||||||
|
'max_rendered_messages' => $config['chat']['max_rendered_messages'] ?? 500,
|
||||||
],
|
],
|
||||||
'ui' => $config['ui'] ?? [],
|
'ui' => $config['ui'] ?? [],
|
||||||
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
|
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
|
||||||
@@ -20,6 +22,5 @@ jsonResponse([
|
|||||||
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
|
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
|
||||||
'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
|
'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
|
||||||
],
|
],
|
||||||
'groups' => ['enabled' => $config['groups']['enabled'] ?? true],
|
|
||||||
'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true],
|
'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true],
|
||||||
]);
|
]);
|
||||||
|
|||||||
-125
@@ -1,125 +0,0 @@
|
|||||||
<?php
|
|
||||||
define('CYBERCHAT_API', true);
|
|
||||||
require_once __DIR__ . '/../bootstrap.php';
|
|
||||||
sendCorsHeaders();
|
|
||||||
|
|
||||||
$user = authRequired();
|
|
||||||
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
|
|
||||||
|
|
||||||
$planGroupsEnabled = (bool)featureValue($user, 'groups_available', true);
|
|
||||||
if (!groupsAvailableForUser($user)) {
|
|
||||||
$error = groupsEnabled()
|
|
||||||
? 'Private groups are not available on your tier'
|
|
||||||
: 'Private groups are currently disabled';
|
|
||||||
if ($action === 'list') {
|
|
||||||
jsonResponse([
|
|
||||||
'groups' => [],
|
|
||||||
'member_limit' => 0,
|
|
||||||
'enabled' => false,
|
|
||||||
'platform_enabled' => groupsEnabled(),
|
|
||||||
'plan_enabled' => $planGroupsEnabled,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
jsonResponse([
|
|
||||||
'error' => $error,
|
|
||||||
'groups_enabled' => false,
|
|
||||||
'groups_platform_enabled' => groupsEnabled(),
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ($action) {
|
|
||||||
case 'list':
|
|
||||||
jsonResponse([
|
|
||||||
'groups' => userGroups((int)$user['id']),
|
|
||||||
'member_limit' => (int)featureValue($user, 'group_member_limit', 2),
|
|
||||||
'enabled' => true,
|
|
||||||
'platform_enabled' => true,
|
|
||||||
'plan_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
case 'create':
|
|
||||||
$name = trim((string)($_POST['name'] ?? ''));
|
|
||||||
$names = array_values(array_unique(array_filter(array_map('trim', explode(',', (string)($_POST['members'] ?? ''))))));
|
|
||||||
if ($name === '' || strlen($name) > 40) jsonResponse(['error' => 'Group name must be 1-40 characters'], 400);
|
|
||||||
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
|
|
||||||
if (count($names) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
|
|
||||||
$memberIds = [];
|
|
||||||
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
|
|
||||||
foreach ($names as $username) {
|
|
||||||
if (strcasecmp($username, $user['username']) === 0) continue;
|
|
||||||
$lookup->execute([$username]);
|
|
||||||
$id = (int)$lookup->fetchColumn();
|
|
||||||
if (!$id) jsonResponse(['error' => "User '$username' was not found"], 404);
|
|
||||||
$memberIds[$id] = $id;
|
|
||||||
}
|
|
||||||
if (!$memberIds) jsonResponse(['error' => 'Add at least one other user to create a group'], 400);
|
|
||||||
if (count($memberIds) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
|
|
||||||
$db = getDB();
|
|
||||||
$db->beginTransaction();
|
|
||||||
$db->prepare("INSERT INTO chat_groups (owner_id,name,created_at,updated_at) VALUES (?,?,?,?)")
|
|
||||||
->execute([$user['id'], $name, time(), time()]);
|
|
||||||
$groupId = (int)$db->lastInsertId();
|
|
||||||
$insert = $db->prepare("INSERT INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,?,?)");
|
|
||||||
$insert->execute([$groupId, $user['id'], 'owner', time()]);
|
|
||||||
foreach ($memberIds as $id) $insert->execute([$groupId, $id, 'member', time()]);
|
|
||||||
$db->commit();
|
|
||||||
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
|
|
||||||
|
|
||||||
case 'add_member':
|
|
||||||
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
|
|
||||||
$username = trim((string)($_POST['username'] ?? ''));
|
|
||||||
$groupStmt = getDB()->prepare("SELECT * FROM chat_groups WHERE id=? AND owner_id=?");
|
|
||||||
$groupStmt->execute([$groupId, $user['id']]);
|
|
||||||
$group = $groupStmt->fetch();
|
|
||||||
if (!$group) jsonResponse(['error' => 'Only the group owner can add members'], 403);
|
|
||||||
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
|
|
||||||
$countStmt = getDB()->prepare("SELECT COUNT(*) FROM group_members WHERE group_id=?");
|
|
||||||
$countStmt->execute([$groupId]);
|
|
||||||
if ((int)$countStmt->fetchColumn() >= $limit) jsonResponse(['error' => "This group has reached your $limit-member limit"], 403);
|
|
||||||
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
|
|
||||||
$lookup->execute([$username]);
|
|
||||||
$memberId = (int)$lookup->fetchColumn();
|
|
||||||
if (!$memberId) jsonResponse(['error' => 'User not found'], 404);
|
|
||||||
getDB()->prepare("INSERT OR IGNORE INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,'member',?)")
|
|
||||||
->execute([$groupId, $memberId, time()]);
|
|
||||||
getDB()->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([time(), $groupId]);
|
|
||||||
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
|
|
||||||
|
|
||||||
case 'remove_member':
|
|
||||||
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
|
|
||||||
$memberId = (int)($_POST['user_id'] ?? 0);
|
|
||||||
$stmt = getDB()->prepare("SELECT owner_id FROM chat_groups WHERE id=?");
|
|
||||||
$stmt->execute([$groupId]);
|
|
||||||
$ownerId = (int)$stmt->fetchColumn();
|
|
||||||
if (!$ownerId) jsonResponse(['error' => 'Group not found'], 404);
|
|
||||||
if ($ownerId !== (int)$user['id'] && $memberId !== (int)$user['id']) jsonResponse(['error' => 'Not allowed'], 403);
|
|
||||||
if ($memberId === $ownerId) jsonResponse(['error' => 'The owner must delete the group instead'], 400);
|
|
||||||
getDB()->prepare("DELETE FROM group_members WHERE group_id=? AND user_id=?")->execute([$groupId, $memberId]);
|
|
||||||
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
|
|
||||||
|
|
||||||
case 'delete':
|
|
||||||
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
|
|
||||||
$db = getDB();
|
|
||||||
$owner = $db->prepare("SELECT 1 FROM chat_groups WHERE id=? AND owner_id=?");
|
|
||||||
$owner->execute([$groupId, $user['id']]);
|
|
||||||
if (!$owner->fetchColumn()) jsonResponse(['error' => 'Only the group owner can delete this group'], 403);
|
|
||||||
$voice = $db->prepare("SELECT voice_file FROM messages WHERE group_id=? AND voice_file IS NOT NULL");
|
|
||||||
$voice->execute([$groupId]);
|
|
||||||
$voiceFiles = $voice->fetchAll();
|
|
||||||
$db->beginTransaction();
|
|
||||||
try {
|
|
||||||
$db->prepare("DELETE FROM recording_status WHERE group_key=?")->execute([groupKey($groupId)]);
|
|
||||||
$db->prepare("DELETE FROM messages WHERE group_id=?")->execute([$groupId]);
|
|
||||||
$db->prepare("DELETE FROM group_members WHERE group_id=?")->execute([$groupId]);
|
|
||||||
$db->prepare("DELETE FROM chat_groups WHERE id=?")->execute([$groupId]);
|
|
||||||
$db->commit();
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
foreach ($voiceFiles as $row) deleteVoiceFile($row['voice_file'] ?? null);
|
|
||||||
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
|
|
||||||
|
|
||||||
default:
|
|
||||||
jsonResponse(['error' => 'Unknown action'], 400);
|
|
||||||
}
|
|
||||||
+84
-74
@@ -6,19 +6,18 @@ sendCorsHeaders();
|
|||||||
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
|
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
|
||||||
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||||
|
|
||||||
switch ($action) {
|
match ($action) {
|
||||||
case 'send': sendTextMessage();
|
'send' => sendTextMessage(),
|
||||||
case 'send_voice': sendVoiceMessage();
|
'send_voice' => sendVoiceMessage(),
|
||||||
case 'recording': setRecordingStatus();
|
'recording' => setRecordingStatus(),
|
||||||
case 'poll': pollMessages();
|
'poll' => pollMessages(),
|
||||||
case 'history': archiveDays();
|
'history' => archiveDays(),
|
||||||
default: jsonResponse(['error' => 'Unknown action'], 400);
|
default => jsonResponse(['error' => 'Unknown action'], 400),
|
||||||
}
|
};
|
||||||
|
|
||||||
function messagePayload(array $row): array {
|
function messagePayload(array $row): array {
|
||||||
return [
|
return [
|
||||||
'id' => (int)$row['id'],
|
'id' => (int)$row['id'],
|
||||||
'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null,
|
|
||||||
'username' => $row['username'],
|
'username' => $row['username'],
|
||||||
'color' => $row['color'],
|
'color' => $row['color'],
|
||||||
'message' => $row['message'],
|
'message' => $row['message'],
|
||||||
@@ -31,98 +30,105 @@ function messagePayload(array $row): array {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestedGroup(array $user): ?int {
|
function publicMessageCondition(PDO $db): string {
|
||||||
$groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null);
|
return isset(tableColumns($db, 'messages')['group_id']) ? ' AND group_id IS NULL' : '';
|
||||||
if ($groupId !== null && !groupsAvailableForUser($user)) {
|
|
||||||
$error = groupsEnabled()
|
|
||||||
? 'Private groups are not available on your tier'
|
|
||||||
: 'Private groups are currently disabled';
|
|
||||||
jsonResponse([
|
|
||||||
'error' => $error,
|
|
||||||
'groups_enabled' => false,
|
|
||||||
'groups_platform_enabled' => groupsEnabled(),
|
|
||||||
], 403);
|
|
||||||
}
|
|
||||||
requireGroupAccess($user, $groupId);
|
|
||||||
return $groupId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTextMessage(): never {
|
function sendTextMessage(): never {
|
||||||
$user = authRequired();
|
$user = authRequired();
|
||||||
$groupId = requestedGroup($user);
|
|
||||||
$message = trim((string)($_POST['message'] ?? ''));
|
$message = trim((string)($_POST['message'] ?? ''));
|
||||||
$max = (int)getConfigVal('chat.max_message_length', 500);
|
$max = (int)getConfigVal('chat.max_message_length', 500);
|
||||||
if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
|
if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
|
||||||
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
|
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
|
||||||
|
|
||||||
$created = time();
|
$created = time();
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
$db->prepare("INSERT INTO messages (user_id,group_id,username,color,message,created_at,day_key)
|
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key)
|
||||||
VALUES (?,?,?,?,?,?,?)")->execute([
|
VALUES (?,?,?,?,?,?)")->execute([
|
||||||
$user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(),
|
$user['id'], $user['username'], $user['color'], $message, $created, dayKey(),
|
||||||
]);
|
]);
|
||||||
if ($groupId !== null) {
|
|
||||||
$db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]);
|
|
||||||
}
|
|
||||||
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
||||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
|
'id' => $db->lastInsertId(),
|
||||||
'color' => $user['color'], 'message' => $message, 'message_type' => 'text',
|
'username' => $user['username'],
|
||||||
'created_at' => $created, 'day_key' => dayKey(),
|
'color' => $user['color'],
|
||||||
|
'message' => $message,
|
||||||
|
'message_type' => 'text',
|
||||||
|
'created_at' => $created,
|
||||||
|
'day_key' => dayKey(),
|
||||||
])]);
|
])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendVoiceMessage(): never {
|
function sendVoiceMessage(): never {
|
||||||
$user = authRequired();
|
$user = authRequired();
|
||||||
$groupId = requestedGroup($user);
|
|
||||||
if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) {
|
if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) {
|
||||||
jsonResponse(['error' => 'Your plan does not include voice messages'], 403);
|
jsonResponse(['error' => 'Your plan does not include voice messages'], 403);
|
||||||
}
|
}
|
||||||
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) jsonResponse(['error' => 'No voice clip uploaded'], 400);
|
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) {
|
||||||
|
jsonResponse(['error' => 'No voice clip uploaded'], 400);
|
||||||
|
}
|
||||||
$file = $_FILES['voice'];
|
$file = $_FILES['voice'];
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) jsonResponse(['error' => 'Voice upload failed'], 400);
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
jsonResponse(['error' => 'Voice upload failed'], 400);
|
||||||
|
}
|
||||||
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
|
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
|
||||||
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
|
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) {
|
||||||
|
jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
|
||||||
|
}
|
||||||
$duration = (float)($_POST['duration'] ?? 0);
|
$duration = (float)($_POST['duration'] ?? 0);
|
||||||
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
|
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
|
||||||
if ($duration <= 0 || $duration > $maxSeconds + 1) jsonResponse(['error' => "Voice clip must be $maxSeconds seconds or less"], 400);
|
if ($duration <= 0 || $duration > $maxSeconds + 1) {
|
||||||
|
jsonResponse(['error' => "Voice clip must be $maxSeconds seconds or less"], 400);
|
||||||
|
}
|
||||||
|
|
||||||
$mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : '';
|
$mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : '';
|
||||||
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12);
|
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12);
|
||||||
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm';
|
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm';
|
||||||
elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav';
|
elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav';
|
||||||
$allowed = ['audio/webm' => 'webm', 'video/webm' => 'webm', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/wave' => 'wav'];
|
$allowed = [
|
||||||
|
'audio/webm' => 'webm',
|
||||||
|
'video/webm' => 'webm',
|
||||||
|
'audio/wav' => 'wav',
|
||||||
|
'audio/x-wav' => 'wav',
|
||||||
|
'audio/wave' => 'wav',
|
||||||
|
];
|
||||||
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400);
|
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400);
|
||||||
|
|
||||||
$dir = voiceUploadDir();
|
$dir = voiceUploadDir();
|
||||||
ensureWritableDirectory($dir, 'Voice storage');
|
ensureWritableDirectory($dir, 'Voice storage');
|
||||||
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
|
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
|
||||||
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) jsonResponse(['error' => 'Could not store voice clip'], 500);
|
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
|
||||||
|
jsonResponse(['error' => 'Could not store voice clip'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
$created = time();
|
$created = time();
|
||||||
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
|
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
try {
|
try {
|
||||||
$db->prepare("INSERT INTO messages
|
$db->prepare("INSERT INTO messages
|
||||||
(user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||||
VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([
|
VALUES (?,?,?,?,'voice',?,?,?,?,?)")->execute([
|
||||||
$user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]',
|
$user['id'], $user['username'], $user['color'], '[Voice clip]',
|
||||||
$filename, $storedMime, $duration, $created, dayKey(),
|
$filename, $storedMime, $duration, $created, dayKey(),
|
||||||
]);
|
]);
|
||||||
if ($groupId !== null) {
|
|
||||||
$db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]);
|
|
||||||
}
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
deleteVoiceFile($filename);
|
deleteVoiceFile($filename);
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
getDB()->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
|
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
|
||||||
->execute([groupKey($groupId), $user['id']]);
|
->execute([$user['id']]);
|
||||||
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
|
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
|
||||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
|
'id' => $db->lastInsertId(),
|
||||||
'color' => $user['color'], 'message' => '[Voice clip]', 'message_type' => 'voice',
|
'username' => $user['username'],
|
||||||
'voice_file' => $filename, 'voice_mime' => $storedMime, 'voice_duration' => $duration,
|
'color' => $user['color'],
|
||||||
'created_at' => $created, 'day_key' => dayKey(),
|
'message' => '[Voice clip]',
|
||||||
|
'message_type' => 'voice',
|
||||||
|
'voice_file' => $filename,
|
||||||
|
'voice_mime' => $storedMime,
|
||||||
|
'voice_duration' => $duration,
|
||||||
|
'created_at' => $created,
|
||||||
|
'day_key' => dayKey(),
|
||||||
])]);
|
])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,60 +137,64 @@ function setRecordingStatus(): never {
|
|||||||
if (!featureValue($user, 'recording_status', false)) {
|
if (!featureValue($user, 'recording_status', false)) {
|
||||||
jsonResponse(['error' => 'Your plan does not include recording indicators'], 403);
|
jsonResponse(['error' => 'Your plan does not include recording indicators'], 403);
|
||||||
}
|
}
|
||||||
$groupId = requestedGroup($user);
|
|
||||||
$active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
$active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
if ($active) {
|
if ($active) {
|
||||||
$db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?)
|
$db->prepare("INSERT INTO recording_status (channel_key,user_id,expires_at) VALUES ('public',?,?)
|
||||||
ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
|
ON CONFLICT(channel_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
|
||||||
->execute([groupKey($groupId), $user['id'], time() + 10]);
|
->execute([$user['id'], time() + 10]);
|
||||||
} else {
|
} else {
|
||||||
$db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
|
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
|
||||||
->execute([groupKey($groupId), $user['id']]);
|
->execute([$user['id']]);
|
||||||
}
|
}
|
||||||
jsonResponse(['success' => true]);
|
jsonResponse(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollMessages(): never {
|
function pollMessages(): never {
|
||||||
$user = authRequired();
|
$user = authRequired();
|
||||||
$groupId = requestedGroup($user);
|
|
||||||
$since = max(0, (int)($_GET['since'] ?? 0));
|
$since = max(0, (int)($_GET['since'] ?? 0));
|
||||||
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
|
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
$whereGroup = $groupId === null ? 'group_id IS NULL' : 'group_id = ?';
|
$publicOnly = publicMessageCondition($db);
|
||||||
$params = $groupId === null ? [dayKey()] : [dayKey(), $groupId];
|
|
||||||
if ($since === 0) {
|
if ($since === 0) {
|
||||||
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup ORDER BY id DESC LIMIT ?");
|
$stmt = $db->prepare("SELECT * FROM messages
|
||||||
$params[] = $limit;
|
WHERE day_key=?$publicOnly ORDER BY id DESC LIMIT ?");
|
||||||
$stmt->execute($params);
|
$stmt->execute([dayKey(), $limit]);
|
||||||
$rows = array_reverse($stmt->fetchAll());
|
$rows = array_reverse($stmt->fetchAll());
|
||||||
|
$hasMore = false;
|
||||||
} else {
|
} else {
|
||||||
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup AND id>? ORDER BY id LIMIT ?");
|
$stmt = $db->prepare("SELECT * FROM messages
|
||||||
$params[] = $since;
|
WHERE day_key=?$publicOnly AND id>? ORDER BY id LIMIT ?");
|
||||||
$params[] = $limit;
|
$stmt->execute([dayKey(), $since, $limit + 1]);
|
||||||
$stmt->execute($params);
|
|
||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
|
$hasMore = count($rows) > $limit;
|
||||||
|
if ($hasMore) $rows = array_slice($rows, 0, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cursor = $since;
|
||||||
|
foreach ($rows as $row) $cursor = max($cursor, (int)$row['id']);
|
||||||
|
|
||||||
$cutoff = time() - 300;
|
$cutoff = time() - 300;
|
||||||
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?");
|
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?");
|
||||||
$onlineStmt->execute([$cutoff]);
|
$onlineStmt->execute([$cutoff]);
|
||||||
|
|
||||||
$recording = [];
|
$recording = [];
|
||||||
$db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]);
|
$db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]);
|
||||||
if (featureValue($user, 'recording_status', false)) {
|
if (featureValue($user, 'recording_status', false)) {
|
||||||
$recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r
|
$recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r
|
||||||
JOIN users u ON u.id=r.user_id WHERE r.group_key=? AND r.expires_at>? AND r.user_id!=?");
|
JOIN users u ON u.id=r.user_id
|
||||||
$recordStmt->execute([groupKey($groupId), time(), $user['id']]);
|
WHERE r.channel_key='public' AND r.expires_at>? AND r.user_id!=?");
|
||||||
|
$recordStmt->execute([time(), $user['id']]);
|
||||||
$recording = $recordStmt->fetchAll();
|
$recording = $recordStmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse([
|
jsonResponse([
|
||||||
'messages' => array_map('messagePayload', $rows),
|
'messages' => array_map('messagePayload', $rows),
|
||||||
|
'cursor' => $cursor,
|
||||||
|
'has_more' => $hasMore,
|
||||||
'online' => (int)$onlineStmt->fetchColumn(),
|
'online' => (int)$onlineStmt->fetchColumn(),
|
||||||
'recording' => $recording,
|
'recording' => $recording,
|
||||||
'groups' => groupsAvailableForUser($user) ? userGroups((int)$user['id']) : [],
|
|
||||||
'groups_enabled' => groupsAvailableForUser($user),
|
|
||||||
'groups_platform_enabled' => groupsEnabled(),
|
|
||||||
'group_id' => $groupId,
|
|
||||||
'server_time' => time(),
|
'server_time' => time(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-267
@@ -4,11 +4,13 @@
|
|||||||
const initialView = new URLSearchParams(location.search).get('view');
|
const initialView = new URLSearchParams(location.search).get('view');
|
||||||
const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || '';
|
const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || '';
|
||||||
const state = {
|
const state = {
|
||||||
config: {}, user: null, groups: [], billing: null,
|
config: {}, user: null, billing: null,
|
||||||
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
|
view: ['chat', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
|
||||||
groupId: null, lastId: 0, pollTimer: null, pollController: null,
|
cursor: 0,
|
||||||
polling: false, pollRequested: false, pollGeneration: 0, groupsAccess: null,
|
poll: {
|
||||||
groupSeen: {}, groupUnread: {},
|
generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null,
|
||||||
|
failures: 0, immediate: false, queue: [], queuedIds: new Set(), initial: true
|
||||||
|
},
|
||||||
loginChallenge: initialChallenge,
|
loginChallenge: initialChallenge,
|
||||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||||
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
playQueue: [], queuePlaying: false, autoplayWarningShown: false, sending: false }
|
playQueue: [], queuePlaying: false, autoplayWarningShown: false, sending: false }
|
||||||
};
|
};
|
||||||
let root;
|
let root;
|
||||||
|
let wakeListenersBound = false;
|
||||||
const $ = (s, c) => (c || root).querySelector(s);
|
const $ = (s, c) => (c || root).querySelector(s);
|
||||||
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
|
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
|
||||||
const esc = value => String(value == null ? '' : value)
|
const esc = value => String(value == null ? '' : value)
|
||||||
@@ -51,30 +54,6 @@
|
|||||||
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
|
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
|
||||||
? state.user.features[key] : fallback;
|
? state.user.features[key] : fallback;
|
||||||
}
|
}
|
||||||
function groupsAvailable() {
|
|
||||||
return state.config.groups?.enabled !== false && state.groupsAccess !== false;
|
|
||||||
}
|
|
||||||
function applyGroupsAvailability(enabled, groups = [], platformEnabled = enabled) {
|
|
||||||
const next = enabled !== false;
|
|
||||||
const changed = groupsAvailable() !== next;
|
|
||||||
state.groupsAccess = next;
|
|
||||||
state.config.groups = { ...(state.config.groups || {}), enabled: platformEnabled !== false };
|
|
||||||
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 = '', platformEnabled = state.config.groups?.enabled !== false) {
|
|
||||||
applyGroupsAvailability(false, [], platformEnabled);
|
|
||||||
if (message) toast(message);
|
|
||||||
app();
|
|
||||||
}
|
|
||||||
function toast(message, type = 'error') {
|
function toast(message, type = 'error') {
|
||||||
const area = $('#cc-toasts');
|
const area = $('#cc-toasts');
|
||||||
if (!area) return;
|
if (!area) return;
|
||||||
@@ -159,10 +138,20 @@
|
|||||||
if (!root) return;
|
if (!root) return;
|
||||||
root.id = 'cyberchat-root';
|
root.id = 'cyberchat-root';
|
||||||
state.config = await api('config.php').catch(() => ({
|
state.config = await api('config.php').catch(() => ({
|
||||||
chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 },
|
chat: {
|
||||||
|
max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000,
|
||||||
|
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500
|
||||||
|
},
|
||||||
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
|
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
|
||||||
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
||||||
}));
|
}));
|
||||||
|
if (!wakeListenersBound) {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) requestPoll();
|
||||||
|
});
|
||||||
|
window.addEventListener('online', requestPoll);
|
||||||
|
wakeListenersBound = true;
|
||||||
|
}
|
||||||
try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {}
|
try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {}
|
||||||
shell();
|
shell();
|
||||||
api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {});
|
api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {});
|
||||||
@@ -266,19 +255,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enter() {
|
async function enter() {
|
||||||
const [groups, billing] = await Promise.all([
|
state.billing = await api('billing.php', null, 'action=status').catch(() => null);
|
||||||
groupsAvailable()
|
|
||||||
? api('groups.php', null, 'action=list').catch(() => ({ groups: [] }))
|
|
||||||
: Promise.resolve({ groups: [], enabled: false, platform_enabled: false }),
|
|
||||||
api('billing.php', null, 'action=status').catch(() => null)
|
|
||||||
]);
|
|
||||||
state.groupsAccess = groups.enabled !== false;
|
|
||||||
state.config.groups = { ...(state.config.groups || {}), enabled: groups.platform_enabled !== false };
|
|
||||||
state.groups = groupsAvailable() ? (groups.groups || []) : [];
|
|
||||||
state.groupSeen = {};
|
|
||||||
state.groupUnread = {};
|
|
||||||
state.groups.forEach(group => { state.groupSeen[group.id] = Number(group.latest_message_id || 0); });
|
|
||||||
state.billing = billing;
|
|
||||||
if (feature('auto_voice_play')) {
|
if (feature('auto_voice_play')) {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('cc_auto_voice_play');
|
const saved = localStorage.getItem('cc_auto_voice_play');
|
||||||
@@ -299,9 +276,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function app() {
|
function app() {
|
||||||
if (!groupsAvailable() && state.view === 'groups') state.view = 'chat';
|
|
||||||
$('#cc-body').innerHTML = `<nav class="cc-app-nav">
|
$('#cc-body').innerHTML = `<nav class="cc-app-nav">
|
||||||
<button data-view="chat">CHAT</button>${groupsAvailable() ? '<button data-view="groups">GROUPS</button>' : ''}
|
<button data-view="chat">CHAT</button>
|
||||||
<button data-view="archive">MY ARCHIVE</button><button data-view="account">ACCOUNT</button>
|
<button data-view="archive">MY ARCHIVE</button><button data-view="account">ACCOUNT</button>
|
||||||
</nav><main class="cc-view" id="cc-view"></main>`;
|
</nav><main class="cc-view" id="cc-view"></main>`;
|
||||||
$$('[data-view]').forEach(button => button.addEventListener('click', () => {
|
$$('[data-view]').forEach(button => button.addEventListener('click', () => {
|
||||||
@@ -315,37 +291,16 @@
|
|||||||
stopVoice(true);
|
stopVoice(true);
|
||||||
$$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view));
|
$$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view));
|
||||||
if (state.view === 'chat') chat();
|
if (state.view === 'chat') chat();
|
||||||
else if (state.view === 'groups' && groupsAvailable()) groupsView();
|
|
||||||
else if (state.view === 'archive') archiveView();
|
else if (state.view === 'archive') archiveView();
|
||||||
else accountView();
|
else accountView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupOptions() {
|
|
||||||
return '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group =>
|
|
||||||
`<option value="${group.id}" ${state.groupId === group.id ? 'selected' : ''}>${state.groupUnread[group.id] ? '● ' : ''}${esc(group.name)} (${group.member_count})</option>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
function syncGroups(groups, markActivity = false) {
|
|
||||||
if (!Array.isArray(groups)) return;
|
|
||||||
groups.forEach(group => {
|
|
||||||
const latest = Number(group.latest_message_id || 0);
|
|
||||||
const seen = Number(state.groupSeen[group.id] || 0);
|
|
||||||
if (markActivity && latest > seen && Number(group.id) !== Number(state.groupId)) {
|
|
||||||
state.groupUnread[group.id] = true;
|
|
||||||
}
|
|
||||||
if (!(group.id in state.groupSeen)) state.groupSeen[group.id] = latest;
|
|
||||||
});
|
|
||||||
state.groups = groups;
|
|
||||||
const select = $('#group-select');
|
|
||||||
if (select) select.innerHTML = groupOptions();
|
|
||||||
}
|
|
||||||
function chat() {
|
function chat() {
|
||||||
const max = state.config.chat?.max_message_length || 500;
|
const max = state.config.chat?.max_message_length || 500;
|
||||||
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
|
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
|
||||||
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
|
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
|
||||||
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
|
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
|
||||||
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div>
|
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div></div>
|
||||||
${groupsAvailable() ? `<select class="cc-compact-select" id="group-select">${groupOptions()}</select>` : ''}</div>
|
|
||||||
<div class="cc-recording-indicator" id="recording" hidden></div>
|
<div class="cc-recording-indicator" id="recording" hidden></div>
|
||||||
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
|
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
|
||||||
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
|
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
|
||||||
@@ -356,16 +311,6 @@
|
|||||||
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
|
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
|
||||||
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
|
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
|
||||||
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
|
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
|
||||||
$('#group-select')?.addEventListener('change', event => {
|
|
||||||
state.groupId = event.target.value ? Number(event.target.value) : null;
|
|
||||||
if (state.groupId !== null) {
|
|
||||||
state.groupUnread[state.groupId] = false;
|
|
||||||
const group = state.groups.find(item => Number(item.id) === state.groupId);
|
|
||||||
if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0);
|
|
||||||
}
|
|
||||||
state.lastId = 0;
|
|
||||||
chat();
|
|
||||||
});
|
|
||||||
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
|
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
|
||||||
$('#message').addEventListener('keydown', event => {
|
$('#message').addEventListener('keydown', event => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
|
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
|
||||||
@@ -380,7 +325,7 @@
|
|||||||
try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {}
|
try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {}
|
||||||
});
|
});
|
||||||
bindAudioPlayers($('#cc-view'));
|
bindAudioPlayers($('#cc-view'));
|
||||||
state.lastId = 0;
|
state.cursor = 0;
|
||||||
startPoll();
|
startPoll();
|
||||||
}
|
}
|
||||||
async function sendText() {
|
async function sendText() {
|
||||||
@@ -388,150 +333,191 @@
|
|||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
const result = await api('messages.php', form({
|
const result = await api('messages.php', form({ action: 'send', message }))
|
||||||
action: 'send', message, group_id: state.groupId || '',
|
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
||||||
})).catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
|
||||||
if (result.groups_enabled === false) {
|
|
||||||
input.value = message;
|
|
||||||
return disableGroups(result.error, result.groups_platform_enabled !== false);
|
|
||||||
}
|
|
||||||
if (result.error) { input.value = message; return toast(result.error); }
|
if (result.error) { input.value = message; return toast(result.error); }
|
||||||
appendMessage(result.message);
|
appendMessage(result.message);
|
||||||
requestPoll();
|
requestPoll();
|
||||||
}
|
}
|
||||||
function startPoll() {
|
function startPoll() {
|
||||||
stopPoll();
|
stopPoll();
|
||||||
const generation = state.pollGeneration;
|
state.cursor = 0;
|
||||||
schedulePoll(generation, 0);
|
state.poll.active = true;
|
||||||
}
|
state.poll.failures = 0;
|
||||||
function schedulePoll(generation, delay = state.config.chat?.poll_interval_ms || 2000) {
|
state.poll.immediate = true;
|
||||||
if (generation !== state.pollGeneration || state.view !== 'chat') return;
|
state.poll.initial = true;
|
||||||
if (state.pollTimer) clearTimeout(state.pollTimer);
|
state.poll.queue = [];
|
||||||
state.pollTimer = setTimeout(async () => {
|
state.poll.queuedIds.clear();
|
||||||
state.pollTimer = null;
|
void pollWorker(state.poll.generation);
|
||||||
await poll(generation);
|
|
||||||
if (generation !== state.pollGeneration || state.view !== 'chat') return;
|
|
||||||
const delay = state.pollRequested ? 0 : state.config.chat?.poll_interval_ms || 2000;
|
|
||||||
state.pollRequested = false;
|
|
||||||
schedulePoll(generation, delay);
|
|
||||||
}, Math.max(0, delay));
|
|
||||||
}
|
}
|
||||||
function requestPoll() {
|
function requestPoll() {
|
||||||
if (state.view !== 'chat') return;
|
if (state.view !== 'chat' || !state.poll.active) return;
|
||||||
if (state.polling) {
|
state.poll.immediate = true;
|
||||||
state.pollRequested = true;
|
const wake = state.poll.wakeResolve;
|
||||||
return;
|
if (wake) wake();
|
||||||
}
|
|
||||||
schedulePoll(state.pollGeneration, 0);
|
|
||||||
}
|
}
|
||||||
function stopPoll() {
|
function stopPoll() {
|
||||||
if (state.pollTimer) clearTimeout(state.pollTimer);
|
state.poll.active = false;
|
||||||
state.pollController?.abort();
|
state.poll.generation++;
|
||||||
state.pollTimer = null;
|
state.poll.controller?.abort();
|
||||||
state.pollController = null;
|
state.poll.controller = null;
|
||||||
state.polling = false;
|
if (state.poll.wakeTimer) clearTimeout(state.poll.wakeTimer);
|
||||||
state.pollRequested = false;
|
state.poll.wakeTimer = null;
|
||||||
state.pollGeneration++;
|
const wake = state.poll.wakeResolve;
|
||||||
|
state.poll.wakeResolve = null;
|
||||||
|
if (wake) wake();
|
||||||
|
state.poll.immediate = false;
|
||||||
|
state.poll.queue = [];
|
||||||
|
state.poll.queuedIds.clear();
|
||||||
}
|
}
|
||||||
async function poll(generation = state.pollGeneration) {
|
async function pollWorker(generation) {
|
||||||
if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return;
|
const interval = Math.max(250, Number(state.config.chat?.poll_interval_ms) || 2000);
|
||||||
const requestedGroup = state.groupId;
|
const maxBackoff = Math.max(interval, Number(state.config.chat?.poll_max_backoff_ms) || 15000);
|
||||||
const requestedSince = state.lastId;
|
while (state.poll.active && state.poll.generation === generation && state.view === 'chat') {
|
||||||
|
state.poll.immediate = false;
|
||||||
|
const result = await pollOnce(generation);
|
||||||
|
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') break;
|
||||||
|
let delay = 0;
|
||||||
|
if (result.ok) {
|
||||||
|
state.poll.failures = 0;
|
||||||
|
if (!result.hasMore && !state.poll.immediate) delay = interval;
|
||||||
|
} else {
|
||||||
|
state.poll.failures++;
|
||||||
|
if (!state.poll.immediate) {
|
||||||
|
delay = Math.min(maxBackoff, 500 * (2 ** Math.min(state.poll.failures - 1, 6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (delay > 0) await waitForPoll(delay, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function waitForPoll(delay, generation) {
|
||||||
|
if (!state.poll.active || state.poll.generation !== generation) return Promise.resolve();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
let timer = null;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (state.poll.wakeTimer === timer) state.poll.wakeTimer = null;
|
||||||
|
if (state.poll.wakeResolve === finish) state.poll.wakeResolve = null;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
timer = setTimeout(finish, Math.max(0, delay));
|
||||||
|
state.poll.wakeTimer = timer;
|
||||||
|
state.poll.wakeResolve = finish;
|
||||||
|
if (state.poll.immediate) finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function pollOnce(generation) {
|
||||||
|
const requestedSince = state.cursor;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000);
|
const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000);
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
state.pollController = controller;
|
state.poll.controller = controller;
|
||||||
state.polling = true;
|
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' });
|
const query = new URLSearchParams({ action: 'poll', since: requestedSince });
|
||||||
const result = await api('messages.php', { signal: controller.signal }, query.toString()).catch(() => null);
|
const result = await api('messages.php', { signal: controller.signal }, query.toString());
|
||||||
if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return;
|
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') {
|
||||||
if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) {
|
return { ok: false, hasMore: false };
|
||||||
const availabilityChanged = applyGroupsAvailability(
|
}
|
||||||
result.groups_enabled !== false,
|
if (!result || result.error || !Array.isArray(result.messages)) {
|
||||||
result.groups || [],
|
throw new Error(result?.error || 'Invalid poll response');
|
||||||
result.groups_platform_enabled !== false
|
}
|
||||||
);
|
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
||||||
if (availabilityChanged) {
|
$('#cc-online-count').textContent = result.online;
|
||||||
toast(groupsAvailable() ? 'Private groups are now available' : 'Private groups have been disabled', 'success');
|
const initial = state.poll.initial;
|
||||||
app();
|
enqueuePollMessages(result.messages, !initial);
|
||||||
return;
|
state.poll.initial = false;
|
||||||
}
|
const messageCursor = result.messages.reduce(
|
||||||
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
(cursor, message) => Math.max(cursor, Number(message.id) || 0),
|
||||||
$('#cc-online-count').textContent = result.online;
|
requestedSince
|
||||||
if (groupsAvailable()) syncGroups(result.groups, true);
|
);
|
||||||
if (requestedSince === 0) renderMessages(result.messages || []);
|
const nextCursor = Math.max(requestedSince, Number(result.cursor) || 0, messageCursor);
|
||||||
else (result.messages || []).forEach(message => appendMessage(message, true));
|
state.cursor = Math.max(state.cursor, nextCursor);
|
||||||
if (result.messages?.length) {
|
const recording = $('#recording');
|
||||||
state.lastId = Math.max(state.lastId, ...result.messages.map(message => Number(message.id) || 0));
|
if (recording) {
|
||||||
}
|
|
||||||
if (requestedGroup !== null) {
|
|
||||||
state.groupUnread[requestedGroup] = false;
|
|
||||||
state.groupSeen[requestedGroup] = Math.max(Number(state.groupSeen[requestedGroup] || 0), Number(state.lastId || 0));
|
|
||||||
}
|
|
||||||
const recording = $('#recording');
|
|
||||||
recording.hidden = !result.recording?.length;
|
recording.hidden = !result.recording?.length;
|
||||||
recording.textContent = result.recording?.length
|
recording.textContent = result.recording?.length
|
||||||
? result.recording.map(user => user.username).join(', ')
|
? result.recording.map(user => user.username).join(', ')
|
||||||
+ (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
|
+ (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
|
||||||
: '';
|
: '';
|
||||||
} else {
|
|
||||||
$('#cc-conn-dot').className = 'cc-conn-dot offline';
|
|
||||||
if (result?.error && requestedGroup !== null) await recoverGroupAccess(requestedGroup, generation);
|
|
||||||
}
|
}
|
||||||
|
return { ok: true, hasMore: Boolean(result.has_more) && nextCursor > requestedSince };
|
||||||
|
} catch (error) {
|
||||||
|
if (state.poll.active && state.poll.generation === generation) {
|
||||||
|
$('#cc-conn-dot').className = 'cc-conn-dot offline';
|
||||||
|
}
|
||||||
|
return { ok: false, hasMore: false };
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (state.pollController === controller) state.pollController = null;
|
if (state.poll.controller === controller) state.poll.controller = null;
|
||||||
if (generation === state.pollGeneration) state.polling = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function recoverGroupAccess(groupId, generation) {
|
function enqueuePollMessages(messages, incoming) {
|
||||||
const result = await api('groups.php', null, 'action=list').catch(() => null);
|
|
||||||
if (!result || result.error || generation !== state.pollGeneration) return;
|
|
||||||
if (result.enabled === false) {
|
|
||||||
disableGroups(
|
|
||||||
result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
|
|
||||||
result.platform_enabled !== false
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
syncGroups(result.groups || []);
|
|
||||||
if (!state.groups.some(group => Number(group.id) === Number(groupId))) {
|
|
||||||
state.groupId = null;
|
|
||||||
state.lastId = 0;
|
|
||||||
toast('Your group access changed; returned to the public lobby');
|
|
||||||
chat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function renderMessages(messages) {
|
|
||||||
const list = $('#messages');
|
const list = $('#messages');
|
||||||
list.innerHTML = messages.length ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
if (!list) return;
|
||||||
messages.forEach(appendMessage);
|
const sorted = [...messages].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
|
||||||
if (messages.length) {
|
sorted.forEach(message => {
|
||||||
state.lastId = Math.max(state.lastId, ...messages.map(message => Number(message.id) || 0));
|
const numericId = Number(message.id);
|
||||||
|
if (!Number.isInteger(numericId) || numericId < 1) return;
|
||||||
|
const id = String(numericId);
|
||||||
|
if (state.poll.queuedIds.has(id) || list.querySelector(`[data-id="${id}"]`)) return;
|
||||||
|
state.poll.queuedIds.add(id);
|
||||||
|
state.poll.queue.push({ message, incoming });
|
||||||
|
});
|
||||||
|
drainPollQueue();
|
||||||
|
if (!list.querySelector('.cc-msg') && state.poll.queue.length === 0) {
|
||||||
|
list.innerHTML = '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
||||||
}
|
}
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
|
function drainPollQueue() {
|
||||||
|
while (state.poll.queue.length) {
|
||||||
|
const entry = state.poll.queue.shift();
|
||||||
|
const id = String(entry.message?.id ?? '');
|
||||||
|
try {
|
||||||
|
appendMessage(entry.message, entry.incoming);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not render chat message', error);
|
||||||
|
} finally {
|
||||||
|
state.poll.queuedIds.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
function appendMessage(message, incoming = false) {
|
function appendMessage(message, incoming = false) {
|
||||||
const list = $('#messages');
|
const list = $('#messages');
|
||||||
if (!list || list.querySelector(`[data-id="${message.id}"]`)) return;
|
const id = Number(message?.id);
|
||||||
|
if (!list || !Number.isInteger(id) || id < 1 || list.querySelector(`[data-id="${id}"]`)) return;
|
||||||
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new';
|
item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new';
|
||||||
item.dataset.id = message.id;
|
item.dataset.id = String(id);
|
||||||
const content = message.message_type === 'voice' && message.voice_url
|
const content = message.message_type === 'voice' && message.voice_url
|
||||||
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
||||||
: linkify(esc(message.message));
|
: linkify(esc(message.message));
|
||||||
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
||||||
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
||||||
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
||||||
list.appendChild(item);
|
const nextMessage = Array.from(list.querySelectorAll('.cc-msg'))
|
||||||
|
.find(existing => Number(existing.dataset.id) > id);
|
||||||
|
if (nextMessage) list.insertBefore(item, nextMessage);
|
||||||
|
else list.appendChild(item);
|
||||||
|
trimMessageBuffer(list);
|
||||||
bindAudioPlayers(item);
|
bindAudioPlayers(item);
|
||||||
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
|
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
|
||||||
enqueueVoiceAutoplay(item);
|
enqueueVoiceAutoplay(item);
|
||||||
}
|
}
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
|
function trimMessageBuffer(list) {
|
||||||
|
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
|
||||||
|
const messages = Array.from(list.querySelectorAll('.cc-msg'));
|
||||||
|
messages.slice(0, Math.max(0, messages.length - limit)).forEach(message => {
|
||||||
|
$('audio', message)?.pause();
|
||||||
|
message.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
function enqueueVoiceAutoplay(item) {
|
function enqueueVoiceAutoplay(item) {
|
||||||
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
||||||
const audio = $('audio', item);
|
const audio = $('audio', item);
|
||||||
@@ -570,7 +556,7 @@
|
|||||||
|
|
||||||
async function recording(active) {
|
async function recording(active) {
|
||||||
if (!feature('recording_status')) return;
|
if (!feature('recording_status')) return;
|
||||||
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0', group_id: state.groupId || '' })).catch(() => {});
|
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0' })).catch(() => {});
|
||||||
}
|
}
|
||||||
async function toggleVoice() {
|
async function toggleVoice() {
|
||||||
if (state.voice.recorder) return stopVoice(false);
|
if (state.voice.recorder) return stopVoice(false);
|
||||||
@@ -644,102 +630,17 @@
|
|||||||
state.voice.sending = true;
|
state.voice.sending = true;
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append('action', 'send_voice');
|
body.append('action', 'send_voice');
|
||||||
body.append('group_id', state.groupId || '');
|
|
||||||
body.append('duration', state.voice.duration.toFixed(2));
|
body.append('duration', state.voice.duration.toFixed(2));
|
||||||
body.append('voice', state.voice.blob, 'voice.webm');
|
body.append('voice', state.voice.blob, 'voice.webm');
|
||||||
const result = await api('messages.php', { method: 'POST', body })
|
const result = await api('messages.php', { method: 'POST', body })
|
||||||
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
||||||
state.voice.sending = false;
|
state.voice.sending = false;
|
||||||
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
|
|
||||||
if (result.error) return toast(result.error);
|
if (result.error) return toast(result.error);
|
||||||
appendMessage(result.message);
|
appendMessage(result.message);
|
||||||
discardVoice();
|
discardVoice();
|
||||||
requestPoll();
|
requestPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupsView() {
|
|
||||||
if (!groupsAvailable()) {
|
|
||||||
state.view = 'chat';
|
|
||||||
renderView();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const limit = Number(feature('group_member_limit', 2));
|
|
||||||
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>GROUPS</h2>
|
|
||||||
<p>Up to ${limit} people per group, including you.</p></div></div>
|
|
||||||
<section class="cc-card"><h3>CREATE GROUP</h3><div class="cc-form-grid">
|
|
||||||
<input class="cc-input" id="group-name" maxlength="40" placeholder="Group name">
|
|
||||||
<input class="cc-input" id="group-members" placeholder="Member callsigns, comma separated">
|
|
||||||
<button class="cc-inline-btn primary" id="group-create">CREATE</button></div></section>
|
|
||||||
<div class="cc-card-grid">${state.groups.length ? state.groups.map(groupCard).join('') : '<div class="cc-empty-card">No private groups yet.</div>'}</div></div>`;
|
|
||||||
$('#group-create').addEventListener('click', createGroup);
|
|
||||||
$$('[data-open]').forEach(button => button.addEventListener('click', () => {
|
|
||||||
state.groupId = Number(button.dataset.open);
|
|
||||||
state.groupUnread[state.groupId] = false;
|
|
||||||
const group = state.groups.find(item => Number(item.id) === state.groupId);
|
|
||||||
if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0);
|
|
||||||
state.view = 'chat';
|
|
||||||
renderView();
|
|
||||||
}));
|
|
||||||
$$('[data-delete]').forEach(button => button.addEventListener('click', () => deleteGroup(Number(button.dataset.delete))));
|
|
||||||
$$('[data-add]').forEach(button => button.addEventListener('click', () => addMember(Number(button.dataset.add))));
|
|
||||||
$$('[data-remove]').forEach(button => button.addEventListener('click', () => {
|
|
||||||
const [groupId, userId] = button.dataset.remove.split(':').map(Number);
|
|
||||||
removeMember(groupId, userId);
|
|
||||||
}));
|
|
||||||
api('groups.php', null, 'action=list').then(result => {
|
|
||||||
if (result.error || state.view !== 'groups') return;
|
|
||||||
if (result.enabled === false) {
|
|
||||||
disableGroups(
|
|
||||||
result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
|
|
||||||
result.platform_enabled !== false
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const before = JSON.stringify(state.groups);
|
|
||||||
syncGroups(result.groups || []);
|
|
||||||
if (JSON.stringify(state.groups) !== before) groupsView();
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
function groupCard(group) {
|
|
||||||
const owner = group.owner_id === Number(state.user.id);
|
|
||||||
return `<section class="cc-card"><div class="cc-card-title"><h3>${esc(group.name)}</h3>
|
|
||||||
<button class="cc-inline-btn" data-open="${group.id}">OPEN CHAT</button></div>
|
|
||||||
<div class="cc-member-list">${group.members.map(member => `<span style="color:${esc(member.color)}">${esc(member.username)}
|
|
||||||
${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` <button data-remove="${group.id}:${member.id}">x</button>` : ''}</span>`).join('')}</div>
|
|
||||||
${owner ? `<div class="cc-form-grid compact"><input class="cc-input" id="add-${group.id}" placeholder="Callsign">
|
|
||||||
<button class="cc-inline-btn" data-add="${group.id}">ADD</button><button class="cc-inline-btn danger" data-delete="${group.id}">DELETE</button></div>` : ''}</section>`;
|
|
||||||
}
|
|
||||||
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, result.groups_platform_enabled !== false);
|
|
||||||
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, result.groups_platform_enabled !== false);
|
|
||||||
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, result.groups_platform_enabled !== false);
|
|
||||||
if (result.error) return toast(result.error);
|
|
||||||
state.groups = result.groups;
|
|
||||||
groupsView();
|
|
||||||
}
|
|
||||||
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, result.groups_platform_enabled !== false);
|
|
||||||
if (result.error) return toast(result.error);
|
|
||||||
state.groups = result.groups;
|
|
||||||
if (state.groupId === groupId) state.groupId = null;
|
|
||||||
groupsView();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveView(search = '') {
|
async function archiveView(search = '') {
|
||||||
$('#cc-view').innerHTML = '<div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING YOUR ARCHIVE</span></div>';
|
$('#cc-view').innerHTML = '<div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING YOUR ARCHIVE</span></div>';
|
||||||
const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString());
|
const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString());
|
||||||
@@ -853,12 +754,7 @@
|
|||||||
stopVoice(true);
|
stopVoice(true);
|
||||||
if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {});
|
if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {});
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.groups = [];
|
|
||||||
state.billing = null;
|
state.billing = null;
|
||||||
state.groupId = null;
|
|
||||||
state.groupsAccess = null;
|
|
||||||
state.groupSeen = {};
|
|
||||||
state.groupUnread = {};
|
|
||||||
state.voice.playQueue = [];
|
state.voice.playQueue = [];
|
||||||
state.voice.queuePlaying = false;
|
state.voice.queuePlaying = false;
|
||||||
auth('login');
|
auth('login');
|
||||||
|
|||||||
+32
-90
@@ -69,14 +69,6 @@ function getConfigVal(string $path, mixed $default = null): mixed {
|
|||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupsEnabled(): bool {
|
|
||||||
return (bool)getConfigVal('groups.enabled', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupsAvailableForUser(array $user): bool {
|
|
||||||
return groupsEnabled() && (bool)featureValue($user, 'groups_available', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConfigValues(array $changes): void {
|
function setConfigValues(array $changes): void {
|
||||||
$config = getConfig();
|
$config = getConfig();
|
||||||
foreach ($changes as $path => $value) {
|
foreach ($changes as $path => $value) {
|
||||||
@@ -214,28 +206,9 @@ function initDB(PDO $db): void {
|
|||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)");
|
)");
|
||||||
|
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS chat_groups (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
owner_id INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)");
|
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS group_members (
|
|
||||||
group_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
joined_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY(group_id, user_id),
|
|
||||||
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)");
|
|
||||||
|
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
group_id INTEGER,
|
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
color TEXT NOT NULL,
|
color TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
@@ -245,11 +218,9 @@ function initDB(PDO $db): void {
|
|||||||
voice_duration REAL,
|
voice_duration REAL,
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
day_key TEXT NOT NULL,
|
day_key TEXT NOT NULL,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE
|
|
||||||
)");
|
)");
|
||||||
ensureColumns($db, 'messages', [
|
ensureColumns($db, 'messages', [
|
||||||
'group_id' => 'INTEGER',
|
|
||||||
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||||||
'voice_file' => 'TEXT',
|
'voice_file' => 'TEXT',
|
||||||
'voice_mime' => 'TEXT',
|
'voice_mime' => 'TEXT',
|
||||||
@@ -315,11 +286,15 @@ function initDB(PDO $db): void {
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)");
|
)");
|
||||||
|
$recordingColumns = tableColumns($db, 'recording_status');
|
||||||
|
if ($recordingColumns && !isset($recordingColumns['channel_key'])) {
|
||||||
|
$db->exec("DROP TABLE recording_status");
|
||||||
|
}
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS recording_status (
|
$db->exec("CREATE TABLE IF NOT EXISTS recording_status (
|
||||||
group_key TEXT NOT NULL,
|
channel_key TEXT NOT NULL,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY(group_key, user_id),
|
PRIMARY KEY(channel_key, user_id),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)");
|
)");
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS visitor_events (
|
$db->exec("CREATE TABLE IF NOT EXISTS visitor_events (
|
||||||
@@ -339,7 +314,6 @@ function initDB(PDO $db): void {
|
|||||||
)");
|
)");
|
||||||
|
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_group ON messages(group_id, id)");
|
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, created_at)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, created_at)");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)");
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)");
|
||||||
@@ -357,28 +331,25 @@ function seedPlans(PDO $db): void {
|
|||||||
'price' => 0, 'sort' => 0,
|
'price' => 0, 'sort' => 0,
|
||||||
'features' => [
|
'features' => [
|
||||||
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
|
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
|
||||||
'auto_voice_play' => false, 'groups_available' => true,
|
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
|
||||||
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
|
|
||||||
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'plus' => [
|
'plus' => [
|
||||||
'name' => 'Plus', 'description' => 'Premium voice, identity, security, and groups for four.',
|
'name' => 'Plus', 'description' => 'Premium voice, identity, security, and 90-day personal archives.',
|
||||||
'price' => 499, 'sort' => 10,
|
'price' => 499, 'sort' => 10,
|
||||||
'features' => [
|
'features' => [
|
||||||
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
|
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
|
||||||
'auto_voice_play' => true, 'groups_available' => true,
|
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 90,
|
||||||
'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90,
|
|
||||||
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
|
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'pro' => [
|
'pro' => [
|
||||||
'name' => 'Pro', 'description' => 'Expanded groups and one year of personal archives.',
|
'name' => 'Pro', 'description' => 'Premium voice, security, and one year of personal archives.',
|
||||||
'price' => 999, 'sort' => 20,
|
'price' => 999, 'sort' => 20,
|
||||||
'features' => [
|
'features' => [
|
||||||
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
|
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
|
||||||
'auto_voice_play' => true, 'groups_available' => true,
|
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 365,
|
||||||
'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365,
|
|
||||||
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
|
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -397,8 +368,11 @@ function seedPlans(PDO $db): void {
|
|||||||
$insertFeature->execute([$planId, $key, json_encode($value)]);
|
$insertFeature->execute([$planId, $key, json_encode($value)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$db->exec("INSERT OR IGNORE INTO plan_features (plan_id,feature_key,value_json)
|
$db->exec("DELETE FROM plan_features WHERE feature_key IN ('groups_available','group_member_limit')");
|
||||||
SELECT id,'groups_available','true' FROM plans");
|
$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.'");
|
||||||
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn();
|
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn();
|
||||||
$db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
|
$db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
|
||||||
}
|
}
|
||||||
@@ -532,43 +506,9 @@ function dayKey(): string {
|
|||||||
return date('Y-m-d');
|
return date('Y-m-d');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeGroupId(mixed $value): ?int {
|
function publicMessagesOnly(PDO $db, string $alias = ''): string {
|
||||||
$id = (int)$value;
|
$prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
|
||||||
return $id > 0 ? $id : null;
|
return isset(tableColumns($db, 'messages')['group_id']) ? " AND {$prefix}group_id IS NULL" : '';
|
||||||
}
|
|
||||||
|
|
||||||
function groupKey(?int $groupId): string {
|
|
||||||
return $groupId ? 'group:' . $groupId : 'lobby';
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireGroupAccess(array $user, ?int $groupId): void {
|
|
||||||
if ($groupId === null) return;
|
|
||||||
$stmt = getDB()->prepare("SELECT 1 FROM group_members WHERE group_id=? AND user_id=?");
|
|
||||||
$stmt->execute([$groupId, $user['id']]);
|
|
||||||
if (!$stmt->fetchColumn()) jsonResponse(['error' => 'You are not a member of this group'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
function userGroups(int $userId): array {
|
|
||||||
$stmt = getDB()->prepare("SELECT g.id,g.name,g.owner_id,g.created_at,gm.role,
|
|
||||||
(SELECT COUNT(*) FROM group_members x WHERE x.group_id=g.id) AS member_count,
|
|
||||||
(SELECT MAX(m.id) FROM messages m WHERE m.group_id=g.id) AS latest_message_id,
|
|
||||||
(SELECT MAX(m.created_at) FROM messages m WHERE m.group_id=g.id) AS latest_message_at
|
|
||||||
FROM chat_groups g JOIN group_members gm ON gm.group_id=g.id
|
|
||||||
WHERE gm.user_id=? ORDER BY g.updated_at DESC,g.id DESC");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$groups = $stmt->fetchAll();
|
|
||||||
$members = getDB()->prepare("SELECT u.id,u.username,u.color,gm.role FROM group_members gm
|
|
||||||
JOIN users u ON u.id=gm.user_id WHERE gm.group_id=? ORDER BY gm.role='owner' DESC,u.username");
|
|
||||||
foreach ($groups as &$group) {
|
|
||||||
$group['id'] = (int)$group['id'];
|
|
||||||
$group['owner_id'] = (int)$group['owner_id'];
|
|
||||||
$group['member_count'] = (int)$group['member_count'];
|
|
||||||
$group['latest_message_id'] = (int)($group['latest_message_id'] ?? 0);
|
|
||||||
$group['latest_message_at'] = (int)($group['latest_message_at'] ?? 0);
|
|
||||||
$members->execute([$group['id']]);
|
|
||||||
$group['members'] = $members->fetchAll();
|
|
||||||
}
|
|
||||||
return $groups;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function voiceUploadDir(): string {
|
function voiceUploadDir(): string {
|
||||||
@@ -595,13 +535,13 @@ function getArchiveDB(string $year, string $month, string $day): PDO {
|
|||||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
$db->exec('PRAGMA journal_mode=WAL');
|
$db->exec('PRAGMA journal_mode=WAL');
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, group_id INTEGER,
|
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
|
||||||
username TEXT NOT NULL,color TEXT NOT NULL,message TEXT 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,
|
message_type TEXT NOT NULL DEFAULT 'text',voice_file TEXT,voice_mime TEXT,
|
||||||
voice_duration REAL,created_at INTEGER NOT NULL,day_key TEXT NOT NULL
|
voice_duration REAL,created_at INTEGER NOT NULL,day_key TEXT NOT NULL
|
||||||
)");
|
)");
|
||||||
ensureColumns($db, 'messages', [
|
ensureColumns($db, 'messages', [
|
||||||
'group_id' => 'INTEGER', 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||||||
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
|
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
|
||||||
]);
|
]);
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)");
|
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)");
|
||||||
@@ -627,7 +567,8 @@ function archiveYesterdayIfNeeded(): void {
|
|||||||
$flag = ROOT_DIR . '/db/.archived_' . $yesterday;
|
$flag = ROOT_DIR . '/db/.archived_' . $yesterday;
|
||||||
if (file_exists($flag)) return;
|
if (file_exists($flag)) return;
|
||||||
$db = getDB();
|
$db = getDB();
|
||||||
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id");
|
$publicOnly = publicMessagesOnly($db);
|
||||||
|
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=?$publicOnly ORDER BY id");
|
||||||
$stmt->execute([$yesterday]);
|
$stmt->execute([$yesterday]);
|
||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
if ($rows) {
|
if ($rows) {
|
||||||
@@ -635,11 +576,11 @@ function archiveYesterdayIfNeeded(): void {
|
|||||||
$archive = getArchiveDB($year, $month, $day);
|
$archive = getArchiveDB($year, $month, $day);
|
||||||
$archive->beginTransaction();
|
$archive->beginTransaction();
|
||||||
$insert = $archive->prepare("INSERT OR IGNORE INTO messages
|
$insert = $archive->prepare("INSERT OR IGNORE INTO messages
|
||||||
(id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$insert->execute([
|
$insert->execute([
|
||||||
$row['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'],
|
$row['id'], $row['user_id'], $row['username'], $row['color'],
|
||||||
$row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'],
|
$row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'],
|
||||||
$row['voice_duration'], $row['created_at'], $row['day_key'],
|
$row['voice_duration'], $row['created_at'], $row['day_key'],
|
||||||
]);
|
]);
|
||||||
@@ -649,7 +590,7 @@ function archiveYesterdayIfNeeded(): void {
|
|||||||
$meta->execute(['message_count', (string)count($rows)]);
|
$meta->execute(['message_count', (string)count($rows)]);
|
||||||
$archive->commit();
|
$archive->commit();
|
||||||
}
|
}
|
||||||
$db->prepare("DELETE FROM messages WHERE day_key=?")->execute([$yesterday]);
|
$db->prepare("DELETE FROM messages WHERE day_key=?$publicOnly")->execute([$yesterday]);
|
||||||
file_put_contents($flag, date('c'), LOCK_EX);
|
file_put_contents($flag, date('c'), LOCK_EX);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,18 +599,19 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
|
|||||||
$cutoff = time() - ($days * 86400);
|
$cutoff = time() - ($days * 86400);
|
||||||
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
|
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
|
||||||
$rows = [];
|
$rows = [];
|
||||||
$sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
|
$db = getDB();
|
||||||
|
$sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($db);
|
||||||
$params = [$user['id'], $cutoff];
|
$params = [$user['id'], $cutoff];
|
||||||
if (!$includeVoice) $sql .= " AND message_type='text'";
|
if (!$includeVoice) $sql .= " AND message_type='text'";
|
||||||
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; }
|
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; }
|
||||||
$stmt = getDB()->prepare($sql . " ORDER BY created_at DESC LIMIT ?");
|
$stmt = $db->prepare($sql . " ORDER BY created_at DESC LIMIT ?");
|
||||||
$params[] = $limit;
|
$params[] = $limit;
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
|
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
|
||||||
$archive = getArchiveDB($year, $month, $day);
|
$archive = getArchiveDB($year, $month, $day);
|
||||||
$archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
|
$archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($archive);
|
||||||
$archiveParams = [$user['id'], $cutoff];
|
$archiveParams = [$user['id'], $cutoff];
|
||||||
if (!$includeVoice) $archiveSql .= " AND message_type='text'";
|
if (!$includeVoice) $archiveSql .= " AND message_type='text'";
|
||||||
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
|
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
|
||||||
|
|||||||
+2
-3
@@ -7,7 +7,9 @@
|
|||||||
"max_username_length": 12,
|
"max_username_length": 12,
|
||||||
"poll_interval_ms": 2000,
|
"poll_interval_ms": 2000,
|
||||||
"poll_timeout_ms": 12000,
|
"poll_timeout_ms": 12000,
|
||||||
|
"poll_max_backoff_ms": 15000,
|
||||||
"messages_per_page": 100,
|
"messages_per_page": 100,
|
||||||
|
"max_rendered_messages": 500,
|
||||||
"session_lock_ip": true,
|
"session_lock_ip": true,
|
||||||
"session_lock_cookie": true,
|
"session_lock_cookie": true,
|
||||||
"allow_guest": false
|
"allow_guest": false
|
||||||
@@ -24,9 +26,6 @@
|
|||||||
"auto_play_default": true,
|
"auto_play_default": true,
|
||||||
"upload_dir": "uploads/voice"
|
"upload_dir": "uploads/voice"
|
||||||
},
|
},
|
||||||
"groups": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"database": {
|
"database": {
|
||||||
"main_db": "db/chat.sqlite",
|
"main_db": "db/chat.sqlite",
|
||||||
"archive_path": "archive/{year}/{month}/{day}.sqlite",
|
"archive_path": "archive/{year}/{month}/{day}.sqlite",
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@
|
|||||||
<title>CyberChat — Embed Example</title>
|
<title>CyberChat — Embed Example</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5">
|
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.6">
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
height: auto;
|
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="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="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.5"</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=20260608.6"</span><span class="kw">></span>
|
||||||
|
|
||||||
<span class="cm"><!-- 3. Container with any dimensions --></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="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="cm"><!-- 4. Script + init --></span>
|
||||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.5"</span><span class="kw">></script></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>
|
<span class="kw"><script></span>
|
||||||
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
||||||
CyberChat.init(<span class="str">'#my-chat'</span>);
|
CyberChat.init(<span class="str">'#my-chat'</span>);
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
|
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#chat-here');
|
CyberChat.init('#chat-here');
|
||||||
|
|||||||
+2
-2
@@ -7,14 +7,14 @@
|
|||||||
<title>Chat @ TyClifford.com</title>
|
<title>Chat @ TyClifford.com</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.5">
|
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.6">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat-v4.js?v=20260608.5"></script>
|
<script src="assets/js/cyberchat-v4.js?v=20260608.6"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#app');
|
CyberChat.init('#app');
|
||||||
|
|||||||
+2
-33
@@ -1,15 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function handleAdminPlatformAction(string $action): ?string {
|
function handleAdminPlatformAction(string $action): ?string {
|
||||||
if ($action === 'save_group_availability') {
|
|
||||||
setConfigValues(['groups.enabled' => !empty($_POST['groups_enabled'])]);
|
|
||||||
return 'Group availability saved.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($action === 'save_platform_services') {
|
if ($action === 'save_platform_services') {
|
||||||
setConfigValues([
|
setConfigValues([
|
||||||
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
|
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
|
||||||
'groups.enabled' => !empty($_POST['groups_enabled']),
|
|
||||||
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
|
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
|
||||||
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
|
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
|
||||||
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
|
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
|
||||||
@@ -33,7 +27,7 @@ function handleAdminPlatformAction(string $action): ?string {
|
|||||||
$db = getDB();
|
$db = getDB();
|
||||||
$plans = $_POST['plans'] ?? [];
|
$plans = $_POST['plans'] ?? [];
|
||||||
$boolFeatures = [
|
$boolFeatures = [
|
||||||
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'groups_available', 'username_color',
|
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color',
|
||||||
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
|
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
|
||||||
];
|
];
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
@@ -62,7 +56,6 @@ function handleAdminPlatformAction(string $action): ?string {
|
|||||||
foreach ($boolFeatures as $key) {
|
foreach ($boolFeatures as $key) {
|
||||||
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
|
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
|
||||||
}
|
}
|
||||||
$upsertFeature->execute([$id, 'group_member_limit', json_encode(max(2, min(100, (int)($plan['features']['group_member_limit'] ?? 2))))]);
|
|
||||||
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
|
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
|
||||||
}
|
}
|
||||||
$db->commit();
|
$db->commit();
|
||||||
@@ -87,8 +80,7 @@ function handleAdminPlatformAction(string $action): ?string {
|
|||||||
$id = (int)$db->lastInsertId();
|
$id = (int)$db->lastInsertId();
|
||||||
$features = [
|
$features = [
|
||||||
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
|
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
|
||||||
'auto_voice_play' => false, 'groups_available' => false,
|
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
|
||||||
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
|
|
||||||
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
||||||
];
|
];
|
||||||
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
|
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
|
||||||
@@ -120,7 +112,6 @@ function renderAdminPlatformTab(): void {
|
|||||||
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
|
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
|
||||||
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
|
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
|
||||||
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
|
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
|
||||||
'groups' => (int)$db->query("SELECT COUNT(*) FROM chat_groups")->fetchColumn(),
|
|
||||||
];
|
];
|
||||||
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
|
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
|
||||||
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
|
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
|
||||||
@@ -136,7 +127,6 @@ function renderAdminPlatformTab(): void {
|
|||||||
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
|
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
|
||||||
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
|
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
|
||||||
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
|
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
|
||||||
<div class="stat-card orange"><div class="stat-num"><?= $stats['groups'] ?></div><div class="stat-label">Private Groups</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -146,10 +136,6 @@ function renderAdminPlatformTab(): void {
|
|||||||
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
|
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
|
||||||
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
|
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
|
||||||
|
|
||||||
<div class="panel"><div class="panel-head"><span class="panel-title">// GROUP AVAILABILITY</span></div>
|
|
||||||
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="groups_enabled" <?= groupsEnabled() ? 'checked' : '' ?>>
|
|
||||||
<span>Enable private groups. When off, group navigation and channels are hidden while existing group data is preserved.</span></label></div></div>
|
|
||||||
|
|
||||||
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
|
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
|
||||||
<div class="form-row3">
|
<div class="form-row3">
|
||||||
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
|
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
|
||||||
@@ -204,23 +190,6 @@ function renderAdminPlatformTab(): void {
|
|||||||
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
|
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
|
||||||
</div>
|
</div>
|
||||||
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
|
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
|
||||||
<div style="border:1px solid var(--bd2);border-radius:4px;padding:12px;margin:12px 0">
|
|
||||||
<div class="panel-title" style="margin-bottom:10px">// GROUPS</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="cb-row" style="align-self:center;margin:0">
|
|
||||||
<input type="checkbox" name="plans[<?= $plan['id'] ?>][features][groups_available]"
|
|
||||||
<?= !empty($plan['features']['groups_available']) ? 'checked' : '' ?>>
|
|
||||||
<span><strong>Enable groups for this tier</strong></span>
|
|
||||||
</label>
|
|
||||||
<div class="field">
|
|
||||||
<label>Maximum People Per Group</label>
|
|
||||||
<input type="number" min="2" max="100" step="1"
|
|
||||||
name="plans[<?= $plan['id'] ?>][features][group_member_limit]"
|
|
||||||
value="<?= max(2, (int)($plan['features']['group_member_limit'] ?? 2)) ?>">
|
|
||||||
<div class="dim" style="margin-top:5px">Minimum 2, including the group owner. Use the toggle to disable groups; no 0 value is needed.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row3">
|
<div class="form-row3">
|
||||||
<?php foreach ($featureKeys as $key): ?>
|
<?php foreach ($featureKeys as $key): ?>
|
||||||
<?php if ($key === 'text_archive_days'): ?>
|
<?php if ($key === 'text_archive_days'): ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user