- 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:
Ty Clifford
2026-06-08 23:28:42 -04:00
parent ef7a9a330f
commit ccdbc47642
12 changed files with 368 additions and 667 deletions
+32 -90
View File
@@ -69,14 +69,6 @@ function getConfigVal(string $path, mixed $default = null): mixed {
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 {
$config = getConfig();
foreach ($changes as $path => $value) {
@@ -214,28 +206,9 @@ function initDB(PDO $db): void {
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
group_id INTEGER,
username TEXT NOT NULL,
color TEXT NOT NULL,
message TEXT NOT NULL,
@@ -245,11 +218,9 @@ function initDB(PDO $db): void {
voice_duration REAL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
day_key TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE
FOREIGN KEY(user_id) REFERENCES users(id)
)");
ensureColumns($db, 'messages', [
'group_id' => 'INTEGER',
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT',
'voice_mime' => 'TEXT',
@@ -315,11 +286,15 @@ function initDB(PDO $db): void {
created_at INTEGER NOT NULL,
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 (
group_key TEXT NOT NULL,
channel_key TEXT NOT NULL,
user_id 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
)");
$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_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_sess_user ON sessions(user_id)");
$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,
'features' => [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
'auto_voice_play' => false, 'groups_available' => true,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
],
],
'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,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
'auto_voice_play' => true, 'groups_available' => true,
'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90,
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 90,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
],
],
'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,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
'auto_voice_play' => true, 'groups_available' => true,
'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365,
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 365,
'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)]);
}
}
$db->exec("INSERT OR IGNORE INTO plan_features (plan_id,feature_key,value_json)
SELECT id,'groups_available','true' FROM plans");
$db->exec("DELETE FROM plan_features WHERE feature_key IN ('groups_available','group_member_limit')");
$db->exec("UPDATE plans SET description='Premium voice, identity, security, and 90-day personal archives.'
WHERE slug='plus' AND description='Premium voice, identity, security, and groups for four.'");
$db->exec("UPDATE plans SET description='Premium voice, security, and one year of personal archives.'
WHERE slug='pro' AND description='Expanded groups and one year of personal archives.'");
$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]);
}
@@ -532,43 +506,9 @@ function dayKey(): string {
return date('Y-m-d');
}
function normalizeGroupId(mixed $value): ?int {
$id = (int)$value;
return $id > 0 ? $id : 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 publicMessagesOnly(PDO $db, string $alias = ''): string {
$prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
return isset(tableColumns($db, 'messages')['group_id']) ? " AND {$prefix}group_id IS NULL" : '';
}
function voiceUploadDir(): string {
@@ -595,13 +535,13 @@ function getArchiveDB(string $year, string $month, string $day): PDO {
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA journal_mode=WAL');
$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,
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
)");
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',
]);
$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;
if (file_exists($flag)) return;
$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]);
$rows = $stmt->fetchAll();
if ($rows) {
@@ -635,11 +576,11 @@ function archiveYesterdayIfNeeded(): void {
$archive = getArchiveDB($year, $month, $day);
$archive->beginTransaction();
$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)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
foreach ($rows as $row) {
$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['voice_duration'], $row['created_at'], $row['day_key'],
]);
@@ -649,7 +590,7 @@ function archiveYesterdayIfNeeded(): void {
$meta->execute(['message_count', (string)count($rows)]);
$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);
}
@@ -658,18 +599,19 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
$cutoff = time() - ($days * 86400);
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
$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];
if (!$includeVoice) $sql .= " AND message_type='text'";
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;
$stmt->execute($params);
$rows = $stmt->fetchAll();
foreach (archiveFilesSince($cutoff) as [, $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];
if (!$includeVoice) $archiveSql .= " AND message_type='text'";
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }