Files
chat/api/messages.php
T
Ty Clifford eb57cde99f - Implemented across bootstrap.php, chat frontend, and dashboard:
Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps.
Configurable text/voice reply threads and depth.
Registration/login-only honeypot and internal CAPTCHA.
Optional Google reCAPTCHA v2/v3 with server verification.
Archive/export support for replies and bitrate.
Dashboard controls for all settings.
Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and 
browser console. Native PHP lint was unavailable because PHP is not 
installed.
Google implementation follows Siteverify and reCAPTCHA v3.
2026-06-09 10:59:58 -04:00

257 lines
11 KiB
PHP

<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/voice_transcoder.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,
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
'reply_depth' => isset($row['reply_depth']) ? (int)$row['reply_depth'] : 0,
'reply_username' => $row['reply_username'] ?? null,
'reply_message' => $row['reply_message'] ?? null,
'reply_message_type' => $row['reply_message_type'] ?? null,
'created_at' => (int)$row['created_at'],
'day_key' => $row['day_key'] ?? dayKey(),
];
}
function messageSelectSql(string $where): string {
return "SELECT m.*,p.username AS reply_username,p.message AS reply_message,
p.message_type AS reply_message_type
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id WHERE $where";
}
function messageById(PDO $db, int $id): array {
$stmt = $db->prepare(messageSelectSql('m.id=?'));
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) throw new RuntimeException('Stored message could not be loaded.');
return $row;
}
function resolveReply(PDO $db): array {
$replyId = max(0, (int)($_POST['reply_to_id'] ?? 0));
if ($replyId === 0) return [null, 0];
if (!getConfigVal('chat.replies_enabled', true)) {
jsonResponse(['error' => 'Replies are disabled'], 403);
}
$maxDepth = max(1, min(20, (int)getConfigVal('chat.reply_max_depth', 4)));
$stmt = $db->prepare("SELECT id,reply_depth FROM messages WHERE id=? AND day_key=?");
$stmt->execute([$replyId, dayKey()]);
$parent = $stmt->fetch();
if (!$parent) jsonResponse(['error' => 'The message being replied to is unavailable'], 404);
$depth = (int)$parent['reply_depth'] + 1;
if ($depth > $maxDepth) {
jsonResponse(['error' => "Reply depth limit reached (max $maxDepth)"], 400);
}
return [$replyId, $depth];
}
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();
[$replyId, $replyDepth] = resolveReply($db);
$db->prepare("INSERT INTO messages
(user_id,username,color,message,reply_to_id,reply_depth,created_at,day_key)
VALUES (?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
$user['id'], $user['username'], $user['color'], $message,
$replyId, $replyDepth, dayKey(),
]);
$messageId = (int)$db->lastInsertId();
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
}
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, 16);
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 (strlen($header) >= 8 && substr($header, 4, 4) === 'ftyp') $mime = 'audio/mp4';
elseif (strlen($header) >= 2 && ord($header[0]) === 0xFF && (ord($header[1]) & 0xF0) === 0xF0) $mime = 'audio/aac';
$allowed = [
'audio/webm' => 'webm',
'video/webm' => 'webm',
'audio/wav' => 'wav',
'audio/x-wav' => 'wav',
'audio/wave' => 'wav',
'audio/mp4' => 'm4a',
'video/mp4' => 'm4a',
'audio/aac' => 'aac',
'audio/x-aac' => 'aac',
];
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WebM, MP4/M4A, AAC, or WAV'], 400);
$db = getDB();
[$replyId, $replyDepth] = resolveReply($db);
$dir = voiceUploadDir();
ensureWritableDirectory($dir, 'Voice storage');
$token = bin2hex(random_bytes(20));
$sourceFilename = $token . '.' . $allowed[$mime];
$sourcePath = $dir . '/' . $sourceFilename;
if (!move_uploaded_file($file['tmp_name'], $sourcePath)) {
jsonResponse(['error' => 'Could not store voice clip'], 500);
}
$sourceMime = match ($mime) {
'video/webm' => 'audio/webm',
'video/mp4' => 'audio/mp4',
'audio/x-wav', 'audio/wave' => 'audio/wav',
'audio/x-aac' => 'audio/aac',
default => $mime,
};
$bitrate = max(48, min(320, (int)featureValue($user, 'voice_bitrate_kbps', 64)));
try {
$stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime, $bitrate);
} catch (Throwable $e) {
if (is_file($sourcePath)) @unlink($sourcePath);
throw $e;
}
$filename = $stored['filename'];
$storedMime = $stored['mime'];
try {
$db->prepare("INSERT INTO messages
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
VALUES (?,?,?,?,'voice',?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
$user['id'], $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, $bitrate, $replyId, $replyDepth, 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']]);
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
}
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(messageSelectSql('m.day_key=?') . " ORDER BY m.id DESC LIMIT ?");
$stmt->execute([dayKey(), $limit]);
$rows = $stmt->fetchAll();
usort($rows, 'compareMessagesChronologically');
$hasMore = false;
} else {
$stmt = $db->prepare(messageSelectSql('m.day_key=? AND m.id>?') . " ORDER BY m.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]);
}