eb57cde99f
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.
257 lines
11 KiB
PHP
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]);
|
|
}
|