- 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.
This commit is contained in:
+21
-2
@@ -17,6 +17,12 @@ if ($action === 'export') {
|
||||
'id' => (int)$row['id'],
|
||||
'username' => $row['username'], 'message' => $row['message'],
|
||||
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']),
|
||||
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
|
||||
'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,
|
||||
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
|
||||
];
|
||||
if ($row['message_type'] === 'voice' && $canVoiceExport && !empty($row['voice_file'])) {
|
||||
$item['voice_url'] = appBaseUrl() . '/' . voicePublicPath($row['voice_file']);
|
||||
@@ -28,10 +34,17 @@ if ($action === 'export') {
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['id', 'username', 'message', 'type', 'created_at', 'voice_url']);
|
||||
fputcsv($out, [
|
||||
'id', 'username', 'message', 'type', 'created_at', 'reply_to_id',
|
||||
'reply_depth', 'reply_username', 'reply_message', 'reply_message_type',
|
||||
'voice_bitrate_kbps', 'voice_url',
|
||||
]);
|
||||
foreach ($export as $row) fputcsv($out, [
|
||||
$row['id'], $row['username'], $row['message'], $row['type'],
|
||||
$row['created_at'], $row['voice_url'] ?? '',
|
||||
$row['created_at'], $row['reply_to_id'] ?? '', $row['reply_depth'],
|
||||
$row['reply_username'] ?? '', $row['reply_message'] ?? '',
|
||||
$row['reply_message_type'] ?? '', $row['voice_bitrate_kbps'] ?? '',
|
||||
$row['voice_url'] ?? '',
|
||||
]);
|
||||
fclose($out);
|
||||
exit;
|
||||
@@ -49,6 +62,12 @@ $payload = array_map(function(array $row): array {
|
||||
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
|
||||
'voice_mime' => $row['voice_mime'] ?? null,
|
||||
'voice_duration' => $row['voice_duration'] !== null ? (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' => (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'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
@@ -6,6 +6,7 @@ sendCorsHeaders();
|
||||
|
||||
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||
match ($action) {
|
||||
'captcha_challenge' => captchaChallenge(),
|
||||
'register' => registerUser(),
|
||||
'login' => loginUser(),
|
||||
'verify_2fa' => verifyTwoFactor(),
|
||||
@@ -14,7 +15,13 @@ match ($action) {
|
||||
default => jsonResponse(['error' => 'Unknown action'], 400),
|
||||
};
|
||||
|
||||
function captchaChallenge(): never {
|
||||
$operation = trim((string)($_GET['operation'] ?? $_POST['operation'] ?? ''));
|
||||
jsonResponse(createAuthCaptchaChallenge($operation));
|
||||
}
|
||||
|
||||
function registerUser(): never {
|
||||
validateAuthCaptcha('register');
|
||||
$db = getDB();
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
@@ -44,6 +51,7 @@ function registerUser(): never {
|
||||
}
|
||||
|
||||
function loginUser(): never {
|
||||
validateAuthCaptcha('login');
|
||||
$db = getDB();
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
|
||||
+14
-1
@@ -12,9 +12,22 @@ jsonResponse([
|
||||
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
|
||||
'poll_max_backoff_ms' => $config['chat']['poll_max_backoff_ms'] ?? 15000,
|
||||
'max_rendered_messages' => $config['chat']['max_rendered_messages'] ?? 500,
|
||||
'replies_enabled' => $config['chat']['replies_enabled'] ?? true,
|
||||
'reply_max_depth' => $config['chat']['reply_max_depth'] ?? 4,
|
||||
],
|
||||
'ui' => $config['ui'] ?? [],
|
||||
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
|
||||
'security' => [
|
||||
'min_password_length' => $config['security']['min_password_length'] ?? 4,
|
||||
'captcha' => [
|
||||
'registration_enabled' => $config['security']['captcha']['registration_enabled'] ?? false,
|
||||
'login_enabled' => $config['security']['captcha']['login_enabled'] ?? false,
|
||||
'honeypot_enabled' => $config['security']['captcha']['honeypot']['enabled'] ?? true,
|
||||
'internal_enabled' => $config['security']['captcha']['internal']['enabled'] ?? false,
|
||||
'google_enabled' => $config['security']['captcha']['google']['enabled'] ?? false,
|
||||
'google_version' => $config['security']['captcha']['google']['version'] ?? 'v2',
|
||||
'google_site_key' => $config['security']['captcha']['google']['site_key'] ?? '',
|
||||
],
|
||||
],
|
||||
'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []],
|
||||
'voice' => [
|
||||
'enabled' => $config['voice']['enabled'] ?? true,
|
||||
|
||||
+56
-41
@@ -27,11 +27,49 @@ function messagePayload(array $row): array {
|
||||
'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'] ?? ''));
|
||||
@@ -40,25 +78,16 @@ function sendTextMessage(): never {
|
||||
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(),
|
||||
[$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();
|
||||
$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(),
|
||||
])]);
|
||||
jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
|
||||
}
|
||||
|
||||
function sendVoiceMessage(): never {
|
||||
@@ -102,6 +131,8 @@ function sendVoiceMessage(): never {
|
||||
];
|
||||
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));
|
||||
@@ -118,21 +149,22 @@ function sendVoiceMessage(): never {
|
||||
'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);
|
||||
$stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime, $bitrate);
|
||||
} catch (Throwable $e) {
|
||||
if (is_file($sourcePath)) @unlink($sourcePath);
|
||||
throw $e;
|
||||
}
|
||||
$filename = $stored['filename'];
|
||||
$storedMime = $stored['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,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, dayKey(),
|
||||
$filename, $storedMime, $duration, $bitrate, $replyId, $replyDepth, dayKey(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
deleteVoiceFile($filename);
|
||||
@@ -141,23 +173,8 @@ function sendVoiceMessage(): never {
|
||||
$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(),
|
||||
])]);
|
||||
jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
|
||||
}
|
||||
|
||||
function setRecordingStatus(): never {
|
||||
@@ -185,15 +202,13 @@ function pollMessages(): never {
|
||||
$db = getDB();
|
||||
|
||||
if ($since === 0) {
|
||||
$stmt = $db->prepare("SELECT * FROM messages
|
||||
WHERE day_key=? ORDER BY id DESC LIMIT ?");
|
||||
$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("SELECT * FROM messages
|
||||
WHERE day_key=? AND id>? ORDER BY id LIMIT ?");
|
||||
$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;
|
||||
|
||||
Reference in New Issue
Block a user