195f3fa56d
One ID-keyed message ledger now handles sends and polls. Messages are always sorted by server ID, latest at bottom. Duplicate DOM rows and voice players are removed. Poll cursors advance only from validated received messages. Voice autoplay queues each received clip once. Added exact user_id ownership tracking. Cache-busted client to 20260609.4.
207 lines
8.1 KiB
PHP
207 lines
8.1 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'],
|
|
'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null,
|
|
'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 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(),
|
|
'user_id' => $user['id'],
|
|
'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(),
|
|
'user_id' => $user['id'],
|
|
'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();
|
|
|
|
if ($since === 0) {
|
|
$stmt = $db->prepare("SELECT * FROM messages
|
|
WHERE day_key=? 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=? 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]);
|
|
}
|