- Voice enhancements, user management, payment method

This commit is contained in:
Ty Clifford
2026-06-08 15:44:15 -04:00
parent 9e3ff61eb7
commit 063b8dc3e8
26 changed files with 2832 additions and 927 deletions
+116 -171
View File
@@ -1,37 +1,24 @@
<?php
// api/messages.php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
// Run archiving silently
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { /* silent */ }
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
$action = $_POST['action'] ?? $_GET['action'] ?? '';
switch ($action) {
case 'send':
handleSend();
break;
case 'send_voice':
handleSendVoice();
break;
case 'poll':
handlePoll();
break;
case 'history':
handleHistory();
break;
default:
jsonResponse(['error' => 'Unknown action'], 400);
case 'send': sendTextMessage();
case 'send_voice': sendVoiceMessage();
case 'recording': setRecordingStatus();
case 'poll': pollMessages();
case 'history': archiveDays();
default: jsonResponse(['error' => 'Unknown action'], 400);
}
function messagePayload(array $row): array {
return [
'id' => (int)$row['id'],
'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null,
'username' => $row['username'],
'color' => $row['color'],
'message' => $row['message'],
@@ -44,191 +31,149 @@ function messagePayload(array $row): array {
];
}
function handleSend(): never {
$session = authRequired();
function requestedGroup(array $user): ?int {
$groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null);
requireGroupAccess($user, $groupId);
return $groupId;
}
function sendTextMessage(): never {
$user = authRequired();
$groupId = requestedGroup($user);
$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();
$message = trim($_POST['message'] ?? '');
$maxLen = getConfigVal('chat.max_message_length', 500);
if (!$message) jsonResponse(['error' => 'Empty message'], 400);
if (strlen($message) > $maxLen) {
jsonResponse(['error' => "Message too long (max {$maxLen} chars)"], 400);
}
$dayKey = dayKey();
$stmt = $db->prepare("
INSERT INTO messages (user_id, username, color, message, created_at, day_key)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$session['uid'],
$session['username'],
$session['color'],
$message,
time(),
$dayKey
]);
$id = $db->lastInsertId();
$db->prepare("INSERT INTO messages (user_id,group_id,username,color,message,created_at,day_key)
VALUES (?,?,?,?,?,?,?)")->execute([
$user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(),
]);
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $id, 'username' => $session['username'], 'color' => $session['color'],
'message' => $message, 'message_type' => 'text', 'created_at' => time(), 'day_key' => $dayKey,
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
'color' => $user['color'], 'message' => $message, 'message_type' => 'text',
'created_at' => $created, 'day_key' => dayKey(),
])]);
}
function handleSendVoice(): never {
$session = authRequired();
if (!getConfigVal('voice.enabled', true)) {
jsonResponse(['error' => 'Voice clips are disabled'], 403);
function sendVoiceMessage(): never {
$user = authRequired();
$groupId = requestedGroup($user);
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);
}
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);
}
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);
}
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);
}
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'])
: '';
$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);
}
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();
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
jsonResponse(['error' => 'Voice storage is unavailable'], 500);
}
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);
}
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) jsonResponse(['error' => 'Could not store voice clip'], 500);
$db = getDB();
$created = time();
$dayKey = dayKey();
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
$db = getDB();
try {
$stmt = $db->prepare("
INSERT INTO messages
(user_id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key)
VALUES (?, ?, ?, ?, 'voice', ?, ?, ?, ?, ?)
");
$stmt->execute([
$session['uid'], $session['username'], $session['color'], '[Voice clip]',
$filename, $mime === 'video/webm' ? 'audio/webm' : $mime, $duration, $created, $dayKey
]);
$db->prepare("INSERT INTO messages
(user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([
$user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, $created, dayKey(),
]);
} catch (Throwable $e) {
deleteVoiceFile($filename);
throw $e;
}
getDB()->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
->execute([groupKey($groupId), $user['id']]);
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $db->lastInsertId(), 'username' => $session['username'], 'color' => $session['color'],
'message' => '[Voice clip]', 'message_type' => 'voice', 'voice_file' => $filename,
'voice_mime' => $mime === 'video/webm' ? 'audio/webm' : $mime,
'voice_duration' => $duration, 'created_at' => $created, 'day_key' => $dayKey,
'id' => $db->lastInsertId(), 'group_id' => $groupId, '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 handlePoll(): never {
$session = authRequired();
function setRecordingStatus(): never {
$user = authRequired();
if (!featureValue($user, 'recording_status', false)) {
jsonResponse(['error' => 'Your plan does not include recording indicators'], 403);
}
$groupId = requestedGroup($user);
$active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN);
$db = getDB();
$since = (int)($_GET['since'] ?? 0);
$dayKey = dayKey();
$limit = getConfigVal('chat.messages_per_page', 100);
if ($since === 0) {
// Initial load — get last N messages for today
$stmt = $db->prepare("
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
FROM messages
WHERE day_key = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
");
$stmt->execute([$dayKey, $limit]);
$rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC));
if ($active) {
$db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?)
ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
->execute([groupKey($groupId), $user['id'], time() + 10]);
} else {
// Poll for new messages since last id
$stmt = $db->prepare("
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
FROM messages
WHERE day_key = ? AND id > ?
ORDER BY created_at ASC, id ASC
LIMIT ?
");
$stmt->execute([$dayKey, $since, $limit]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
->execute([groupKey($groupId), $user['id']]);
}
jsonResponse(['success' => true]);
}
function pollMessages(): never {
$user = authRequired();
$groupId = requestedGroup($user);
$since = max(0, (int)($_GET['since'] ?? 0));
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
$db = getDB();
$whereGroup = $groupId === null ? 'group_id IS NULL' : 'group_id = ?';
$params = $groupId === null ? [dayKey()] : [dayKey(), $groupId];
if ($since === 0) {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup ORDER BY id DESC LIMIT ?");
$params[] = $limit;
$stmt->execute($params);
$rows = array_reverse($stmt->fetchAll());
} else {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup AND id>? ORDER BY id LIMIT ?");
$params[] = $since;
$params[] = $limit;
$stmt->execute($params);
$rows = $stmt->fetchAll();
}
// Cast types
$messages = array_map('messagePayload', $rows);
// Get online user count (active in last 5 minutes)
$cutoff5min = time() - 300;
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active > ?");
$onlineStmt->execute([$cutoff5min]);
$onlineCount = (int)$onlineStmt->fetchColumn();
$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.group_key=? AND r.expires_at>? AND r.user_id!=?");
$recordStmt->execute([groupKey($groupId), time(), $user['id']]);
$recording = $recordStmt->fetchAll();
}
jsonResponse([
'messages' => $messages,
'online' => $onlineCount,
'day_key' => $dayKey,
'messages' => array_map('messagePayload', $rows),
'online' => (int)$onlineStmt->fetchColumn(),
'recording' => $recording,
'group_id' => $groupId,
'server_time' => time(),
]);
}
function handleHistory(): never {
// Return list of archived days
$archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive');
$days = [];
if (is_dir($archiveDir)) {
$years = glob($archiveDir . '/*', GLOB_ONLYDIR);
foreach ($years as $yearDir) {
$year = basename($yearDir);
$months = glob($yearDir . '/*', GLOB_ONLYDIR);
foreach ($months as $monthDir) {
$month = basename($monthDir);
$files = glob($monthDir . '/*.sqlite');
foreach ($files as $file) {
$day = basename($file, '.sqlite');
$days[] = "$year-$month-$day";
}
}
}
}
function archiveDays(): never {
$user = authRequired();
$rows = personalArchiveRows($user, '', 1000);
$days = array_values(array_unique(array_column($rows, 'day_key')));
rsort($days);
jsonResponse(['days' => $days]);
}