ccdbc47642
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.
209 lines
8.2 KiB
PHP
209 lines
8.2 KiB
PHP
<?php
|
|
define('CYBERCHAT_API', true);
|
|
require_once __DIR__ . '/../bootstrap.php';
|
|
sendCorsHeaders();
|
|
|
|
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
|
|
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
|
|
|
match ($action) {
|
|
'send' => sendTextMessage(),
|
|
'send_voice' => sendVoiceMessage(),
|
|
'recording' => setRecordingStatus(),
|
|
'poll' => pollMessages(),
|
|
'history' => archiveDays(),
|
|
default => jsonResponse(['error' => 'Unknown action'], 400),
|
|
};
|
|
|
|
function messagePayload(array $row): array {
|
|
return [
|
|
'id' => (int)$row['id'],
|
|
'username' => $row['username'],
|
|
'color' => $row['color'],
|
|
'message' => $row['message'],
|
|
'message_type' => $row['message_type'] ?? 'text',
|
|
'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
|
|
'voice_mime' => $row['voice_mime'] ?? null,
|
|
'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : null,
|
|
'created_at' => (int)$row['created_at'],
|
|
'day_key' => $row['day_key'] ?? dayKey(),
|
|
];
|
|
}
|
|
|
|
function publicMessageCondition(PDO $db): string {
|
|
return isset(tableColumns($db, 'messages')['group_id']) ? ' AND group_id IS NULL' : '';
|
|
}
|
|
|
|
function sendTextMessage(): never {
|
|
$user = authRequired();
|
|
$message = trim((string)($_POST['message'] ?? ''));
|
|
$max = (int)getConfigVal('chat.max_message_length', 500);
|
|
if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
|
|
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
|
|
|
|
$created = time();
|
|
$db = getDB();
|
|
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key)
|
|
VALUES (?,?,?,?,?,?)")->execute([
|
|
$user['id'], $user['username'], $user['color'], $message, $created, dayKey(),
|
|
]);
|
|
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
|
'id' => $db->lastInsertId(),
|
|
'username' => $user['username'],
|
|
'color' => $user['color'],
|
|
'message' => $message,
|
|
'message_type' => 'text',
|
|
'created_at' => $created,
|
|
'day_key' => dayKey(),
|
|
])]);
|
|
}
|
|
|
|
function sendVoiceMessage(): never {
|
|
$user = authRequired();
|
|
if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) {
|
|
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);
|
|
}
|
|
$file = $_FILES['voice'];
|
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
|
jsonResponse(['error' => 'Voice upload failed'], 400);
|
|
}
|
|
$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);
|
|
}
|
|
$duration = (float)($_POST['duration'] ?? 0);
|
|
$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);
|
|
}
|
|
|
|
$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);
|
|
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';
|
|
$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);
|
|
|
|
$dir = voiceUploadDir();
|
|
ensureWritableDirectory($dir, 'Voice storage');
|
|
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
|
|
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
|
|
jsonResponse(['error' => 'Could not store voice clip'], 500);
|
|
}
|
|
|
|
$created = time();
|
|
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
|
|
$db = getDB();
|
|
try {
|
|
$db->prepare("INSERT INTO messages
|
|
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
|
VALUES (?,?,?,?,'voice',?,?,?,?,?)")->execute([
|
|
$user['id'], $user['username'], $user['color'], '[Voice clip]',
|
|
$filename, $storedMime, $duration, $created, dayKey(),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
deleteVoiceFile($filename);
|
|
throw $e;
|
|
}
|
|
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
|
|
->execute([$user['id']]);
|
|
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
|
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
|
'id' => $db->lastInsertId(),
|
|
'username' => $user['username'],
|
|
'color' => $user['color'],
|
|
'message' => '[Voice clip]',
|
|
'message_type' => 'voice',
|
|
'voice_file' => $filename,
|
|
'voice_mime' => $storedMime,
|
|
'voice_duration' => $duration,
|
|
'created_at' => $created,
|
|
'day_key' => dayKey(),
|
|
])]);
|
|
}
|
|
|
|
function setRecordingStatus(): never {
|
|
$user = authRequired();
|
|
if (!featureValue($user, 'recording_status', false)) {
|
|
jsonResponse(['error' => 'Your plan does not include recording indicators'], 403);
|
|
}
|
|
$active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
|
$db = getDB();
|
|
if ($active) {
|
|
$db->prepare("INSERT INTO recording_status (channel_key,user_id,expires_at) VALUES ('public',?,?)
|
|
ON CONFLICT(channel_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
|
|
->execute([$user['id'], time() + 10]);
|
|
} else {
|
|
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
|
|
->execute([$user['id']]);
|
|
}
|
|
jsonResponse(['success' => true]);
|
|
}
|
|
|
|
function pollMessages(): never {
|
|
$user = authRequired();
|
|
$since = max(0, (int)($_GET['since'] ?? 0));
|
|
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
|
|
$db = getDB();
|
|
$publicOnly = publicMessageCondition($db);
|
|
|
|
if ($since === 0) {
|
|
$stmt = $db->prepare("SELECT * FROM messages
|
|
WHERE day_key=?$publicOnly ORDER BY id DESC LIMIT ?");
|
|
$stmt->execute([dayKey(), $limit]);
|
|
$rows = array_reverse($stmt->fetchAll());
|
|
$hasMore = false;
|
|
} else {
|
|
$stmt = $db->prepare("SELECT * FROM messages
|
|
WHERE day_key=?$publicOnly AND id>? ORDER BY id LIMIT ?");
|
|
$stmt->execute([dayKey(), $since, $limit + 1]);
|
|
$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;
|
|
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?");
|
|
$onlineStmt->execute([$cutoff]);
|
|
|
|
$recording = [];
|
|
$db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]);
|
|
if (featureValue($user, 'recording_status', false)) {
|
|
$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.channel_key='public' AND r.expires_at>? AND r.user_id!=?");
|
|
$recordStmt->execute([time(), $user['id']]);
|
|
$recording = $recordStmt->fetchAll();
|
|
}
|
|
|
|
jsonResponse([
|
|
'messages' => array_map('messagePayload', $rows),
|
|
'cursor' => $cursor,
|
|
'has_more' => $hasMore,
|
|
'online' => (int)$onlineStmt->fetchColumn(),
|
|
'recording' => $recording,
|
|
'server_time' => time(),
|
|
]);
|
|
}
|
|
|
|
function archiveDays(): never {
|
|
$user = authRequired();
|
|
$rows = personalArchiveRows($user, '', 1000);
|
|
$days = array_values(array_unique(array_column($rows, 'day_key')));
|
|
rsort($days);
|
|
jsonResponse(['days' => $days]);
|
|
}
|