Files
chat/api/messages.php
T
Ty Clifford ae0fb45c48 - Fixed the actual race:
Sent messages wait for polling to catch up before rendering.
Missing interleaved messages cannot appear above later ones.
Messages sort by server timestamp, then ID.
Timestamps are assigned atomically during SQLite insertion.
Client updated to 20260609.5.
2026-06-09 01:35:19 -04:00

219 lines
8.8 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);
$db = getDB();
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key)
VALUES (?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
$user['id'], $user['username'], $user['color'], $message, dayKey(),
]);
$messageId = (int)$db->lastInsertId();
$createdStmt = $db->prepare("SELECT created_at FROM messages WHERE id=?");
$createdStmt->execute([$messageId]);
$created = (int)$createdStmt->fetchColumn();
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $messageId,
'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);
}
$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',?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
$user['id'], $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, dayKey(),
]);
} catch (Throwable $e) {
deleteVoiceFile($filename);
throw $e;
}
$messageId = (int)$db->lastInsertId();
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
->execute([$user['id']]);
$createdStmt = $db->prepare("SELECT created_at FROM messages WHERE id=?");
$createdStmt->execute([$messageId]);
$created = (int)$createdStmt->fetchColumn();
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $messageId,
'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 = $stmt->fetchAll();
usort($rows, 'compareMessagesChronologically');
$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 compareMessagesChronologically(array $a, array $b): int {
return ((int)$a['created_at'] <=> (int)$b['created_at'])
?: ((int)$a['id'] <=> (int)$b['id']);
}
function archiveDays(): never {
$user = authRequired();
$rows = personalArchiveRows($user, '', 1000);
$days = array_values(array_unique(array_column($rows, 'day_key')));
rsort($days);
jsonResponse(['days' => $days]);
}