diff --git a/README.md b/README.md index 4f4aba1..b1903ad 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# CyberChat 2.1.0 +# CyberChat 2.2.0 -CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with text and voice messaging, configurable service tiers, Stripe subscriptions, Mailgun email verification, private archives, resilient queued polling, an administrator dashboard, and optional FFmpeg and MySQL/MariaDB integrations. +CyberChat 2.2.0 is the current baseline release. It is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with text and voice messaging, configurable service tiers, Stripe subscriptions, Mailgun email verification, private archives, resilient queued polling, an administrator dashboard, and optional FFmpeg and MySQL/MariaDB integrations. ## Current Capabilities ### Chat and Interface - Authenticated public chat with text and voice messages +- Reply-to controls for text and voice with configurable thread depth - Chronological message reconciliation with the newest message at the bottom - Single-worker cursor polling with catch-up batches, deduplication, request timeouts, retry backoff, and immediate wake-up after sending - Poll recovery when the browser returns online or the tab becomes visible @@ -22,6 +23,7 @@ CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQL ### Voice and Audio - Browser microphone recording with a configurable duration and upload-size limit +- Per-tier recording and FFmpeg output bitrate from 48 to 320 kbps - WebM/Opus recording by default on supported browsers - MP4/AAC and AAC recording fallback for Safari and iPhone - Upload validation for WebM, WAV, MP4/M4A, and AAC @@ -55,6 +57,9 @@ CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQL - Mailgun-based email delivery through a reusable cURL mailer - Tier-configurable email two-factor authentication - Six-digit login challenges with expiration and attempt limits +- Registration/login-only internal arithmetic CAPTCHA +- Invisible registration/login honeypot fields that reject bot-like submissions +- Optional Google reCAPTCHA v2 checkbox or v3 score/action verification - Account view for email verification, 2FA, plan details, billing, and current feature entitlements - Tier-configurable custom username colors - Administrator user creation, username/color/password editing, session termination, and deletion @@ -67,6 +72,7 @@ CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQL - Per-tier feature toggles and archive retention - Current configurable entitlements: - Voice messages + - Voice bitrate - Recording-status sending and viewing - Automatic voice send - Automatic voice play @@ -189,6 +195,7 @@ The defaults are starting points. Plans, prices, and every listed entitlement ca | Feature | Free | Plus | Pro | |---|---:|---:|---:| | Voice messages | Yes | Yes | Yes | +| Voice bitrate | 64 kbps | 96 kbps | 128 kbps | | Recording status send/view | No | Yes | Yes | | Automatic voice send | No | Yes | Yes | | Automatic voice play | No | Yes | Yes | @@ -217,7 +224,7 @@ Important settings: | `voice.transcoding.enabled` | Enable server-side FFmpeg conversion | | `voice.transcoding.ffmpeg_path` | FFmpeg executable name or absolute path | | `voice.transcoding.profile` | `m4a_aac` or `mp4_h264_aac` | -| `voice.transcoding.audio_bitrate_kbps` | AAC bitrate from 48 to 320 kbps | +| `voice.transcoding.audio_bitrate_kbps` | Fallback AAC bitrate when no tier override is supplied | | `voice.transcoding.timeout_seconds` | Conversion timeout from 5 to 300 seconds | | `voice.transcoding.fallback_to_source` | Keep the source file if conversion fails | @@ -239,6 +246,8 @@ When FFmpeg is enabled: - New uploads are converted; existing files are not rewritten. - The original file can be retained automatically if FFmpeg fails. +The authenticated user's `voice_bitrate_kbps` tier entitlement controls the browser MediaRecorder target and overrides the FFmpeg fallback bitrate for new clips. + The web server must serve `.webm`, `.wav`, `.aac`, `.m4a`, and `.mp4` with the MIME mappings shown in `.htaccess` and `nginx-example.conf`. ## Mailgun @@ -305,6 +314,27 @@ User archive behavior: Voice files remain in `uploads/voice/` while their metadata moves between live and archive databases. Administrator deletion actions remove the associated recording file. +Reply IDs, reply depth, parent previews, and voice bitrate metadata are retained in daily archives and personal JSON/CSV exports. + +## CAPTCHA + +CAPTCHA checks are scoped to registration and initial password login. Email 2FA code verification, chat messages, account updates, and dashboard actions do not invoke CAPTCHA. + +Dashboard controls under **Plans & Platform > Registration / Login CAPTCHA** configure: + +- Registration and login protection independently +- Invisible honeypot protection +- Internal arithmetic challenge difficulty, expiration, and attempt limit +- Google reCAPTCHA v2 checkbox or v3 score mode +- Google site key, secret key, expected hostname, and v3 minimum score + +Google tokens are verified server-side through the Siteverify API. For v3, CyberChat also validates the expected `register` or `login` action and the configured score threshold. + +Google references: + +- +- + ## Polling and Message Ordering CyberChat uses one polling worker per active chat view: @@ -331,7 +361,7 @@ This design avoids overlapping poll requests, duplicate voice players, and tempo | Archives | Browse days, inspect file sizes, filter/edit/delete messages, delete days | | Users | Create users, edit credentials/colors, assign tiers, kick, delete | | Sessions | Inspect active/expired sessions and terminate one, expired, or all | -| Plans & Platform | Plans, pricing, entitlements, Stripe, Mailgun, FFmpeg, MySQL, statistics | +| Plans & Platform | Plans, pricing, bitrate, replies, CAPTCHA, Stripe, Mailgun, FFmpeg, MySQL, statistics | | Config | Edit and validate `config.json` directly | ## Configuration Families @@ -339,12 +369,12 @@ This design avoids overlapping poll requests, duplicate voice players, and tempo `config.json` is editable through **Admin > Config**. The primary groups are: - `app`: installation base URL -- `chat`: text limits, polling, browser buffer, and session restrictions +- `chat`: text limits, polling, browser buffer, replies, and session restrictions - `archive`: archive enablement and directory - `voice`: recording, upload, playback defaults, storage, and FFmpeg - `database`: main SQLite path, archive template, and optional MySQL mirror - `ui`: application title and subtitle -- `security`: password, session, bcrypt, and CORS settings +- `security`: password, session, bcrypt, CORS, and registration/login CAPTCHA settings - `subscriptions`: new-subscription visibility - `mailgun`: verification and 2FA delivery - `stripe`: Checkout, webhook, and Billing Portal settings @@ -392,12 +422,12 @@ The diagnostic endpoint exposes deployment details and should be restricted or r ## Embedding ```html - +
- + diff --git a/admin.php b/admin.php index ff6cc7b..dedb817 100644 --- a/admin.php +++ b/admin.php @@ -10,7 +10,7 @@ define('ROOT_DIR', __DIR__); define('CONFIG_FILE', ROOT_DIR . '/config.json'); -define('ADMIN_VERSION', '2.1.0'); +define('ADMIN_VERSION', '2.2.0'); require_once ROOT_DIR . '/bootstrap.php'; require_once ROOT_DIR . '/lib/admin_platform.php'; @@ -47,6 +47,9 @@ function ensureAdminMessageColumns(PDO $d): void { 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', + 'voice_bitrate_kbps' => 'INTEGER', + 'reply_to_id' => 'INTEGER', + 'reply_depth' => 'INTEGER NOT NULL DEFAULT 0', ] as $name => $definition) { if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition"); } @@ -62,6 +65,7 @@ function archiveDB(string $year, string $month, string $day): ?PDO { ensureAdminMessageColumns($a); removeLegacyGroupSchema($a, false); $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); + $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)"); return $a; } @@ -84,6 +88,9 @@ function createArchiveDB(string $year, string $month, string $day): PDO { voice_file TEXT, voice_mime TEXT, voice_duration REAL, + voice_bitrate_kbps INTEGER, + reply_to_id INTEGER, + reply_depth INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, day_key TEXT NOT NULL )"); @@ -94,6 +101,7 @@ function createArchiveDB(string $year, string $month, string $day): PDO { value TEXT )"); $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); + $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)"); return $a; } @@ -223,11 +231,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { } if ($existingFile === false) { $insert = $adb->prepare("INSERT INTO messages - (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) - VALUES (?,?,?,?,?,?,?,?,?,?,?)"); + (id,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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)"); $insert->execute([ $row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'], + $row['voice_bitrate_kbps'], $row['reply_to_id'], $row['reply_depth'], $row['created_at'], $row['day_key'] ]); } @@ -1114,12 +1124,14 @@ td.actions{white-space:nowrap;width:1px} -
VOICE · s
+
VOICE · s +
+
Reply # · depth
@@ -1392,12 +1404,14 @@ td.actions{white-space:nowrap;width:1px} -
VOICE · s
+
VOICE · s +
+
Reply # · depth
@@ -1724,6 +1738,8 @@ td.actions{white-space:nowrap;width:1px} chat.poll_timeout_msintegerAbort and retry a stalled poll after this many milliseconds chat.poll_max_backoff_msintegerMaximum delay after repeated poll failures chat.max_rendered_messagesintegerMaximum chat rows retained in the browser + chat.replies_enabledboolEnable reply-to and threaded display + chat.reply_max_depthintegerMaximum nested reply depth chat.session_lock_ipboolLock session to IP voice.enabledboolShow voice recording controls voice.max_duration_secondsintegerMaximum voice clip duration @@ -1736,6 +1752,7 @@ td.actions{white-space:nowrap;width:1px} voice.transcoding.timeout_secondsintegerFFmpeg upload conversion timeout voice.transcoding.fallback_to_sourceboolRetain WebM/MP4 source when conversion fails security.min_password_lengthintegerMin password chars + security.captcha.*objectRegistration/login honeypot, internal, and Google CAPTCHA settings security.session_timeout_hoursintegerSession expiry in hours security.bcrypt_costintegerbcrypt work factor (10–14) ui.app_titlestringHeader title text diff --git a/api/archive.php b/api/archive.php index a6334f5..52a3996 100644 --- a/api/archive.php +++ b/api/archive.php @@ -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); diff --git a/api/auth.php b/api/auth.php index 34cb563..9949fd4 100644 --- a/api/auth.php +++ b/api/auth.php @@ -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'] ?? ''); diff --git a/api/config.php b/api/config.php index 08d30e1..3af18d0 100644 --- a/api/config.php +++ b/api/config.php @@ -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, diff --git a/api/messages.php b/api/messages.php index 810e31e..213e5d8 100644 --- a/api/messages.php +++ b/api/messages.php @@ -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; diff --git a/assets/css/cyberchat.css b/assets/css/cyberchat.css index 88c20b3..de0219a 100644 --- a/assets/css/cyberchat.css +++ b/assets/css/cyberchat.css @@ -283,7 +283,7 @@ input, button { font-family: inherit; } .cc-auth-panel { width: 100%; - max-width: 340px; + max-width: 380px; background: var(--bg1); border: 1px solid var(--bd); border-radius: var(--r2); @@ -505,6 +505,50 @@ input, button { font-family: inherit; } opacity: 1; } +.cc-captcha-trap { + position: absolute !important; + left: -10000px !important; + top: auto !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + opacity: 0 !important; + pointer-events: none !important; +} + +.cc-captcha-block { + margin: 4px 0 13px; + padding: 10px; + border: 1px solid var(--bd); + background: var(--bg2); +} + +.cc-captcha-row { + display: grid; + grid-template-columns: minmax(72px, auto) 1fr auto; + align-items: center; + gap: 7px; + color: var(--g); + font: 11px var(--mono); +} + +.cc-captcha-row .cc-input { + min-width: 0; + padding: 6px 8px; +} + +.cc-captcha-refresh { + height: 31px; + padding: 0 8px; + border: 1px solid var(--b); + background: transparent; + color: var(--b); + font: 8px var(--mono); +} + +.cc-google-captcha { margin-top: 10px; min-height: 78px; } +.cc-captcha-note { margin-top: 7px; color: var(--t1); font: 8px var(--mono); } + /* ────────────────────────────────────────────────────────────────────────── CHAT SCREEN ────────────────────────────────────────────────────────────────────────── */ @@ -604,6 +648,8 @@ input, button { font-family: inherit; } } .cc-msg:hover { background: rgba(255,255,255,0.02); } +.cc-msg-thread { padding-left: calc(12px + var(--cc-reply-indent, 0px)); } +.cc-msg-focus { background: rgba(0,184,255,0.12); box-shadow: inset 2px 0 0 var(--b); } .cc-msg-time { font-size: 9px; @@ -641,6 +687,37 @@ input, button { font-family: inherit; } .cc-msg-own .cc-msg-text { color: #ddeeff; } +.cc-reply-ref { + display: flex; + max-width: min(100%, 470px); + margin: 0 0 3px; + padding: 3px 6px; + gap: 6px; + border: 0; + border-left: 2px solid var(--v); + background: rgba(191,95,255,0.07); + color: var(--t1); + font: 9px var(--mono); + text-align: left; +} + +.cc-reply-ref b { color: var(--v); flex-shrink: 0; } +.cc-reply-ref span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.cc-msg-reply { + align-self: center; + margin-left: 7px; + padding: 2px 5px; + border: 1px solid transparent; + background: transparent; + color: var(--t2); + font: 8px var(--mono); + opacity: 0; +} + +.cc-msg:hover .cc-msg-reply, +.cc-msg-reply:focus { opacity: 1; border-color: var(--bd); color: var(--b); } + .cc-voice-message { display: inline-flex; align-items: center; @@ -1040,6 +1117,28 @@ input, button { font-family: inherit; } .cc-voice-action.send { border-color: var(--g); color: var(--g); } .cc-voice-action:disabled { opacity: 0.35; cursor: not-allowed; } +.cc-reply-context { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 5px 10px; + border-top: 1px solid rgba(191,95,255,0.35); + background: rgba(191,95,255,0.07); + color: var(--t1); + font: 8px var(--mono); +} + +.cc-reply-context[hidden] { display: none; } +.cc-reply-context b { color: var(--v); } +.cc-reply-context > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cc-reply-context button { + border: 1px solid var(--r); + background: transparent; + color: var(--r); + font: 8px var(--mono); +} + /* Compose bar */ .cc-compose { display: flex; @@ -1184,6 +1283,7 @@ html, body { .cc-archive-row { display: grid; grid-template-columns: 145px 55px 1fr; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--bd); background: var(--bg1); font: 11px var(--mono); } .cc-archive-row time { color: var(--t1); font-size: 9px; } .cc-archive-row audio { display: none; } +.cc-archive-reply { margin-bottom: 5px; padding-left: 6px; border-left: 2px solid var(--v); color: var(--t1); font-size: 9px; } .cc-type-tag { color: var(--o); font-size: 8px; text-transform: uppercase; } .cc-switch { display: flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--t0); font: 10px var(--mono); } .cc-switch input { accent-color: var(--g); } @@ -1203,4 +1303,10 @@ html, body { .cc-cyber-audio { min-width: 0; width: 100%; grid-template-columns: 26px 18px minmax(60px, 1fr) 64px 32px; } .cc-compact-select { max-width: 145px; } .cc-page { padding: 12px; } + .cc-msg-reply { opacity: 1; } +} + +@media (max-width: 360px) { + .cc-google-captcha { width: 268px; overflow: hidden; } + .cc-google-captcha > div { transform: scale(.88); transform-origin: left top; } } diff --git a/assets/js/cyberchat-app.js b/assets/js/cyberchat-app.js index 93284c6..1502660 100644 --- a/assets/js/cyberchat-app.js +++ b/assets/js/cyberchat-app.js @@ -13,6 +13,8 @@ }, loginChallenge: initialChallenge, verifyToken: new URLSearchParams(location.search).get('verify') || '', + reply: null, + captcha: { internal: {}, widgets: {}, scriptPromise: null }, voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0, chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false, playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false } @@ -20,7 +22,7 @@ let root; let wakeListenersBound = false; const displayedFeatures = new Set([ - 'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', + 'voice_messages', 'voice_bitrate_kbps', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'voice_export', 'email_2fa' ]); @@ -151,9 +153,11 @@ state.config = await api('config.php').catch(() => ({ chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000, - poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500 + poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500, + replies_enabled: true, reply_max_depth: 4 }, - security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 }, + security: { min_password_length: 4, captcha: {} }, + voice: { enabled: true, max_duration_seconds: 60 }, ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' } })); if (!wakeListenersBound) { @@ -192,8 +196,141 @@ }); } + function authCaptchaEnabled(operation) { + const captcha = state.config.security?.captcha || {}; + return operation === 'register' + ? captcha.registration_enabled === true + : captcha.login_enabled === true; + } + function authCaptchaMarkup(operation) { + if (!authCaptchaEnabled(operation)) return ''; + const captcha = state.config.security?.captcha || {}; + return `${captcha.honeypot_enabled ? `` : ''} +
+ ${captcha.internal_enabled ? `
+ +
LOADING... + +
+
` : ''} + ${captcha.google_enabled && captcha.google_version === 'v2' + ? `
` : ''} + ${captcha.google_enabled && captcha.google_version === 'v3' + ? '
Protected by reCAPTCHA v3
' : ''} +
`; + } + async function loadInternalCaptcha(operation) { + if (!authCaptchaEnabled(operation) || !state.config.security?.captcha?.internal_enabled) return; + const result = await api('auth.php', null, new URLSearchParams({ + action: 'captcha_challenge', operation + }).toString()).catch(() => ({ error: 'Could not load CAPTCHA' })); + const question = $(`#${operation}-captcha-question`); + if (result.error || !result.enabled) { + state.captcha.internal[operation] = null; + if (question) question.textContent = 'UNAVAILABLE'; + return; + } + state.captcha.internal[operation] = result.token; + if (question) question.textContent = result.question; + const answer = $(`#${operation}-captcha-answer`); + if (answer) answer.value = ''; + } + function loadGoogleCaptcha() { + const captcha = state.config.security?.captcha || {}; + if (!captcha.google_enabled || !captcha.google_site_key) return Promise.resolve(); + if (window.grecaptcha) return Promise.resolve(); + if (state.captcha.scriptPromise) return state.captcha.scriptPromise; + state.captcha.scriptPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + const render = captcha.google_version === 'v3' + ? encodeURIComponent(captcha.google_site_key) : 'explicit'; + script.src = `https://www.google.com/recaptcha/api.js?render=${render}`; + script.async = true; + script.defer = true; + script.onload = resolve; + script.onerror = () => reject(new Error('Google reCAPTCHA could not load')); + document.head.appendChild(script); + }); + return state.captcha.scriptPromise; + } + async function renderGoogleCaptcha(operation) { + const captcha = state.config.security?.captcha || {}; + if (!authCaptchaEnabled(operation) || !captcha.google_enabled + || captcha.google_version !== 'v2' || !captcha.google_site_key) return; + await loadGoogleCaptcha(); + const target = $(`#${operation}-google-captcha`); + if (!target || state.captcha.widgets[operation] != null) return; + state.captcha.widgets[operation] = window.grecaptcha.render(target, { + sitekey: captcha.google_site_key, + theme: document.body.classList.contains('cc-light') ? 'light' : 'dark', + }); + } + async function setupAuthCaptcha() { + ['register', 'login'].forEach(operation => { + $(`#${operation}-captcha-refresh`)?.addEventListener('click', () => loadInternalCaptcha(operation)); + }); + const activeOperation = $('[data-form].active')?.dataset.form; + if (activeOperation === 'register' || activeOperation === 'login') { + void loadInternalCaptcha(activeOperation); + } + const captcha = state.config.security?.captcha || {}; + if (!captcha.google_enabled || !captcha.google_site_key) return; + try { + await loadGoogleCaptcha(); + const active = $('[data-form].active')?.dataset.form; + if (active === 'register' || active === 'login') await renderGoogleCaptcha(active); + } catch (error) { + authError(error.message); + } + } + async function authForm(operation, values) { + const body = new FormData(); + Object.entries(values).forEach(([key, value]) => body.append(key, value)); + const captcha = state.config.security?.captcha || {}; + if (!authCaptchaEnabled(operation)) return { method: 'POST', body }; + if (captcha.honeypot_enabled) { + body.append('_hp_website', $(`#${operation}-hp-website`)?.value || ''); + body.append('_hp_contact', $(`#${operation}-hp-contact`)?.checked ? '1' : '0'); + } + if (captcha.internal_enabled) { + const token = state.captcha.internal[operation]; + const answer = $(`#${operation}-captcha-answer`)?.value.trim() || ''; + if (!token || !answer) throw new Error('Complete the human check'); + body.append('captcha_internal_token', token); + body.append('captcha_internal_answer', answer); + } + if (captcha.google_enabled) { + if (!captcha.google_site_key || !window.grecaptcha) throw new Error('Google reCAPTCHA is unavailable'); + let token = ''; + if (captcha.google_version === 'v3') { + token = await new Promise((resolve, reject) => { + window.grecaptcha.ready(() => { + window.grecaptcha.execute(captcha.google_site_key, { action: operation }).then(resolve, reject); + }); + }); + } else { + const widget = state.captcha.widgets[operation]; + if (widget == null) throw new Error('Google reCAPTCHA is still loading'); + token = window.grecaptcha.getResponse(widget); + } + if (!token) throw new Error('Complete the Google reCAPTCHA check'); + body.append('captcha_google_token', token); + } + return { method: 'POST', body }; + } + function refreshAuthCaptcha(operation) { + void loadInternalCaptcha(operation); + const widget = state.captcha.widgets[operation]; + if (widget != null && window.grecaptcha) window.grecaptcha.reset(widget); + } + function auth(tab) { stopPoll(); + state.captcha.internal = {}; + state.captcha.widgets = {}; const maxUser = state.config.chat?.max_username_length || 12; const minPass = state.config.security?.min_password_length || 4; $('#cc-body').innerHTML = `
@@ -207,11 +344,13 @@
+ ${authCaptchaMarkup('register')}

Welcome back.

+ ${authCaptchaMarkup('login')}

Enter the code sent to your verified email.

@@ -221,11 +360,16 @@ $('#register').addEventListener('click', register); $('#login').addEventListener('click', login); if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge)); + void setupAuthCaptcha(); } function switchAuth(name) { $$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name)); $$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name)); authError(''); + if (name === 'register' || name === 'login') { + if (!state.captcha.internal[name]) void loadInternalCaptcha(name); + renderGoogleCaptcha(name).catch(error => authError(error.message)); + } } function authError(message) { const item = $('#auth-error'); @@ -237,16 +381,29 @@ const password = $('#reg-pass').value; if (!username || !password) return authError('Username and password are required'); if (password !== $('#reg-pass2').value) return authError('Passphrases do not match'); - const result = await api('auth.php', form({ action: 'register', username, password })); - if (result.error) return authError(result.error); + let request; + try { request = await authForm('register', { action: 'register', username, password }); } + catch (error) { return authError(error.message); } + const result = await api('auth.php', request); + if (result.error) { + refreshAuthCaptcha('register'); + return authError(result.error); + } state.user = result.user; await enter(); } async function login() { - const result = await api('auth.php', form({ - action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value - })); - if (result.error) return authError(result.error); + let request; + try { + request = await authForm('login', { + action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value + }); + } catch (error) { return authError(error.message); } + const result = await api('auth.php', request); + if (result.error) { + refreshAuthCaptcha('login'); + return authError(result.error); + } if (result.requires_2fa) { state.loginChallenge = result.challenge; switchAuth('2fa'); @@ -306,20 +463,51 @@ else accountView(); } + function voiceBitrate() { + return Math.max(48, Math.min(320, Number(feature('voice_bitrate_kbps', 64)) || 64)); + } + function replyPreviewText(message) { + if (!message) return ''; + return message.message_type === 'voice' ? '[Voice clip]' : String(message.message || '').slice(0, 120); + } + function setReply(message) { + state.reply = { + id: Number(message.id), + username: message.username, + message: replyPreviewText(message), + depth: Number(message.reply_depth) || 0, + }; + const bar = $('#reply-context'); + if (bar) { + bar.hidden = false; + $('#reply-context-user').textContent = state.reply.username; + $('#reply-context-text').textContent = state.reply.message; + } + $('#message')?.focus(); + } + function clearReply() { + state.reply = null; + const bar = $('#reply-context'); + if (bar) bar.hidden = true; + } + function chat() { const max = state.config.chat?.max_message_length || 500; const voice = state.config.voice?.enabled !== false && feature('voice_messages', true); + state.reply = null; $('#cc-view').innerHTML = `
YOU:${esc(state.user.username)} ${esc(state.user.plan.name)}
LOADING CHANNEL
${voice ? `
- VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s + VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS ${feature('auto_voice_send') ? `` : ''} ${feature('auto_voice_play') ? `` : ''}
` : ''} +
${max}
`; $('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length); @@ -327,6 +515,7 @@ if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); } }); $('#send').addEventListener('click', sendText); + $('#reply-cancel').addEventListener('click', clearReply); $('#record')?.addEventListener('click', toggleVoice); $('#voice-send')?.addEventListener('click', sendVoice); $('#voice-discard')?.addEventListener('click', discardVoice); @@ -343,11 +532,20 @@ const input = $('#message'); const message = input.value.trim(); if (!message) return; + const max = state.config.chat?.max_message_length || 500; input.value = ''; - const result = await api('messages.php', form({ action: 'send', message })) + $('#count').textContent = max; + const result = await api('messages.php', form({ + action: 'send', message, reply_to_id: state.reply?.id || '' + })) .catch(() => ({ error: 'Message send failed. Polling will continue.' })); - if (result.error) { input.value = message; return toast(result.error); } + if (result.error) { + input.value = message; + $('#count').textContent = max - message.length; + return toast(result.error); + } acknowledgeSentMessage(result.message); + clearReply(); requestPoll(); } function startPoll() { @@ -471,7 +669,14 @@ function normalizeMessage(message) { const id = Number(message?.id); if (!Number.isSafeInteger(id) || id < 1) return null; - return { ...message, id }; + const replyToId = Number(message?.reply_to_id); + return { + ...message, + id, + reply_to_id: Number.isSafeInteger(replyToId) && replyToId > 0 ? replyToId : null, + reply_depth: Math.max(0, Number(message?.reply_depth) || 0), + voice_bitrate_kbps: Number(message?.voice_bitrate_kbps) || null, + }; } function acknowledgeSentMessage(message) { const acknowledged = normalizeMessage(message); @@ -489,7 +694,9 @@ return JSON.stringify([ message.user_id ?? null, message.username, message.color, message.message, message.message_type, message.voice_url, message.voice_mime, - message.voice_duration, message.created_at + message.voice_duration, message.voice_bitrate_kbps, message.reply_to_id, + message.reply_depth, message.reply_username, message.reply_message, + message.reply_message_type, message.created_at ]); } function compareMessagesChronologically(a, b) { @@ -562,16 +769,39 @@ } function createMessageElement(message) { const id = message.id; + const depth = Math.max(0, Number(message.reply_depth) || 0); + const maxDepth = Math.max(1, Number(state.config.chat?.reply_max_depth) || 4); const item = document.createElement('div'); - item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + ' cc-msg-new'; + item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + + (depth ? ' cc-msg-thread' : '') + ' cc-msg-new'; + item.style.setProperty('--cc-reply-indent', `${Math.min(depth, 6) * 12}px`); item.dataset.id = String(id); item.dataset.renderKey = messageRenderKey(message); const content = message.message_type === 'voice' && message.voice_url - ? `VOICE ${duration(message.voice_duration)}${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}` + ? `VOICE ${duration(message.voice_duration)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps} KBPS` : ''}${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}` : linkify(esc(message.message)); + const parentText = message.reply_message_type === 'voice' + ? '[Voice clip]' + : (message.reply_message || 'Original message unavailable'); + const replyReference = message.reply_to_id + ? `` + : ''; + const replyAction = state.config.chat?.replies_enabled !== false && depth < maxDepth + ? '' : ''; item.innerHTML = `${clock(message.created_at)} ${esc(message.username)} - >${content}`; + >${replyReference}${content}${replyAction}`; + $('.cc-msg-reply', item)?.addEventListener('click', () => setReply(message)); + $('.cc-reply-ref', item)?.addEventListener('click', event => { + const parentId = Number(event.currentTarget.dataset.parentId); + const parent = $(`.cc-msg[data-id="${parentId}"]`); + if (!parent) return toast('Original message is outside the current view', 'info'); + parent.scrollIntoView({ behavior: 'smooth', block: 'center' }); + parent.classList.add('cc-msg-focus'); + setTimeout(() => parent.classList.remove('cc-msg-focus'), 1200); + }); bindAudioPlayers(item); return item; } @@ -644,15 +874,25 @@ ]; let recorder = null; let mime = ''; + const audioBitsPerSecond = voiceBitrate() * 1000; for (const candidate of candidates) { if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue; try { - recorder = new MediaRecorder(stream, { mimeType: candidate }); + recorder = new MediaRecorder(stream, { mimeType: candidate, audioBitsPerSecond }); mime = candidate; break; - } catch {} + } catch { + try { + recorder = new MediaRecorder(stream, { mimeType: candidate }); + mime = candidate; + break; + } catch {} + } + } + if (!recorder) { + try { recorder = new MediaRecorder(stream, { audioBitsPerSecond }); } + catch { recorder = new MediaRecorder(stream); } } - if (!recorder) recorder = new MediaRecorder(stream); state.voice.stream = stream; state.voice.chunks = []; state.voice.started = Date.now(); @@ -677,7 +917,9 @@ function voiceTimer() { const max = state.config.voice?.max_duration_seconds || 60; const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max); - if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`; + if ($('#voice-status')) { + $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)} · ${voiceBitrate()} KBPS`; + } if (elapsed >= max) stopVoice(false); } function stopVoice(discard) { @@ -706,7 +948,7 @@ $('#voice-preview').src = state.voice.url; $('#voice-preview').load(); $('#voice-review').hidden = false; - $('#voice-status').textContent = 'READY ' + duration(length); + $('#voice-status').textContent = `READY ${duration(length)} · ${voiceBitrate()} KBPS`; } function discardVoice() { if (state.voice.recorder) stopVoice(true); @@ -716,6 +958,9 @@ state.voice.mime = ''; state.voice.duration = 0; if ($('#voice-review')) $('#voice-review').hidden = true; + if ($('#voice-status') && !state.voice.recorder) { + $('#voice-status').textContent = `VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS`; + } } async function sendVoice() { if (!state.voice.blob || state.voice.sending) return; @@ -723,6 +968,7 @@ const body = new FormData(); body.append('action', 'send_voice'); body.append('duration', state.voice.duration.toFixed(2)); + body.append('reply_to_id', state.reply?.id || ''); const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a' : state.voice.mime.includes('aac') ? 'voice.aac' : state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm'; @@ -733,6 +979,7 @@ if (result.error) return toast(result.error); acknowledgeSentMessage(result.message); discardVoice(); + clearReply(); requestPoll(); } @@ -753,8 +1000,13 @@ function archiveRow(message) { const content = message.message_type === 'voice' && message.voice_url ? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message); + const reply = message.reply_to_id + ? `
Reply to ${esc(message.reply_username || `#${message.reply_to_id}`)}: + ${esc(message.reply_message_type === 'voice' ? '[Voice clip]' : (message.reply_message || 'Original unavailable'))}
` + : ''; return `
- ${esc(message.message_type)}
${content}
`; + ${esc(message.message_type)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps}k` : ''} +
${reply}${content}
`; } async function accountView() { diff --git a/bootstrap.php b/bootstrap.php index 7a49c70..3ce2571 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -191,13 +191,18 @@ function removeLegacyGroupSchema(PDO $db, bool $mainDatabase): void { voice_file TEXT, voice_mime TEXT, voice_duration REAL, + voice_bitrate_kbps INTEGER, + reply_to_id INTEGER, + reply_depth INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), day_key TEXT NOT NULL" . ($mainDatabase ? ", FOREIGN KEY(user_id) REFERENCES users(id)" : '') . " )"); $db->exec("INSERT INTO messages_without_groups - (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) - SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key + (id,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) + SELECT id,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 FROM messages WHERE group_id IS NULL"); $db->exec("DROP TABLE messages"); $db->exec("ALTER TABLE messages_without_groups RENAME TO messages"); @@ -268,6 +273,9 @@ function initDB(PDO $db): void { voice_file TEXT, voice_mime TEXT, voice_duration REAL, + voice_bitrate_kbps INTEGER, + reply_to_id INTEGER, + reply_depth INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), day_key TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) @@ -277,6 +285,9 @@ function initDB(PDO $db): void { 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', + 'voice_bitrate_kbps' => 'INTEGER', + 'reply_to_id' => 'INTEGER', + 'reply_depth' => 'INTEGER NOT NULL DEFAULT 0', ]); removeLegacyGroupSchema($db, true); @@ -339,6 +350,14 @@ function initDB(PDO $db): void { created_at INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )"); + $db->exec("CREATE TABLE IF NOT EXISTS auth_captcha_challenges ( + id TEXT PRIMARY KEY, + operation TEXT NOT NULL, + answer_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + )"); $recordingColumns = tableColumns($db, 'recording_status'); if ($recordingColumns && !isset($recordingColumns['channel_key'])) { $db->exec("DROP TABLE recording_status"); @@ -368,9 +387,11 @@ function initDB(PDO $db): void { $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, created_at)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_reply ON messages(reply_to_id)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_event_created ON visitor_events(created_at)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_auth_captcha_expires ON auth_captcha_challenges(expires_at)"); $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL"); seedPlans($db); @@ -383,7 +404,8 @@ function seedPlans(PDO $db): void { 'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.', 'price' => 0, 'sort' => 0, 'features' => [ - 'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false, + 'voice_messages' => true, 'voice_bitrate_kbps' => 64, + 'recording_status' => false, 'auto_voice_send' => false, 'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30, 'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, ], @@ -392,7 +414,8 @@ function seedPlans(PDO $db): void { 'name' => 'Plus', 'description' => 'Premium voice, identity, security, and 90-day personal archives.', 'price' => 499, 'sort' => 10, 'features' => [ - 'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, + 'voice_messages' => true, 'voice_bitrate_kbps' => 96, + 'recording_status' => true, 'auto_voice_send' => true, 'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 90, 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, ], @@ -401,7 +424,8 @@ function seedPlans(PDO $db): void { 'name' => 'Pro', 'description' => 'Premium voice, security, and one year of personal archives.', 'price' => 999, 'sort' => 20, 'features' => [ - 'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, + 'voice_messages' => true, 'voice_bitrate_kbps' => 128, + 'recording_status' => true, 'auto_voice_send' => true, 'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 365, 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, ], @@ -512,6 +536,128 @@ function clientIP(): string { return '0.0.0.0'; } +function authCaptchaEnabledFor(string $operation): bool { + if (!in_array($operation, ['register', 'login'], true)) return false; + $key = $operation === 'register' ? 'registration_enabled' : 'login_enabled'; + return (bool)getConfigVal('security.captcha.' . $key, false); +} + +function createAuthCaptchaChallenge(string $operation): array { + if (!in_array($operation, ['register', 'login'], true)) { + jsonResponse(['error' => 'Invalid CAPTCHA operation'], 400); + } + if (!authCaptchaEnabledFor($operation) || !getConfigVal('security.captcha.internal.enabled', false)) { + return ['enabled' => false]; + } + + $difficulty = (string)getConfigVal('security.captcha.internal.difficulty', 'easy'); + $upper = $difficulty === 'medium' ? 25 : 9; + $left = random_int(1, $upper); + $right = random_int(1, $upper); + $answer = $left + $right; + $ttl = max(60, min(1800, (int)getConfigVal('security.captcha.internal.ttl_seconds', 300))); + $id = bin2hex(random_bytes(24)); + $hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt'); + $db = getDB(); + $db->prepare("DELETE FROM auth_captcha_challenges WHERE expires_at<=?")->execute([time()]); + $db->prepare("INSERT INTO auth_captcha_challenges + (id,operation,answer_hash,expires_at,created_at) VALUES (?,?,?,?,?)") + ->execute([$id, $operation, hash_hmac('sha256', (string)$answer, $hashKey), time() + $ttl, time()]); + + return [ + 'enabled' => true, + 'token' => $id, + 'question' => "$left + $right = ?", + 'expires_in' => $ttl, + ]; +} + +function authCaptchaFailure(string $reason): never { + error_log('Authentication CAPTCHA rejected: ' . $reason); + trackEvent('captcha_failed', '/api/auth.php'); + jsonResponse(['error' => 'CAPTCHA verification failed'], 403); +} + +function validateAuthCaptcha(string $operation): void { + if (!authCaptchaEnabledFor($operation)) return; + + if (getConfigVal('security.captcha.honeypot.enabled', true)) { + $website = trim((string)($_POST['_hp_website'] ?? '')); + $contact = filter_var($_POST['_hp_contact'] ?? false, FILTER_VALIDATE_BOOLEAN); + if ($website !== '' || $contact) authCaptchaFailure('honeypot'); + } + + if (getConfigVal('security.captcha.internal.enabled', false)) { + $token = trim((string)($_POST['captcha_internal_token'] ?? '')); + $answer = trim((string)($_POST['captcha_internal_answer'] ?? '')); + if ($token === '' || $answer === '') authCaptchaFailure('internal'); + $db = getDB(); + $stmt = $db->prepare("SELECT * FROM auth_captcha_challenges + WHERE id=? AND operation=? AND expires_at>?"); + $stmt->execute([$token, $operation, time()]); + $challenge = $stmt->fetch(); + $maxAttempts = max(1, min(10, (int)getConfigVal('security.captcha.internal.max_attempts', 3))); + $hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt'); + $answerHash = hash_hmac('sha256', $answer, $hashKey); + if (!$challenge || (int)$challenge['attempts'] >= $maxAttempts + || !hash_equals((string)$challenge['answer_hash'], $answerHash)) { + if ($challenge) { + $db->prepare("UPDATE auth_captcha_challenges SET attempts=attempts+1 WHERE id=?") + ->execute([$token]); + if ((int)$challenge['attempts'] + 1 >= $maxAttempts) { + $db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]); + } + } + authCaptchaFailure('internal'); + } + $db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]); + } + + if (!getConfigVal('security.captcha.google.enabled', false)) return; + $secret = trim((string)getConfigVal('security.captcha.google.secret_key', '')); + $responseToken = trim((string)($_POST['captcha_google_token'] ?? '')); + if ($secret === '' || $responseToken === '') authCaptchaFailure('google'); + if (!function_exists('curl_init')) { + throw new RuntimeException('Google reCAPTCHA requires the PHP cURL extension.'); + } + + $curl = curl_init('https://www.google.com/recaptcha/api/siteverify'); + curl_setopt_array($curl, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'secret' => $secret, + 'response' => $responseToken, + 'remoteip' => clientIP(), + ]), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], + ]); + $raw = curl_exec($curl); + $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($raw === false || $status < 200 || $status >= 300) { + error_log('Google reCAPTCHA verification failed: ' . ($error ?: 'HTTP ' . $status)); + authCaptchaFailure('google'); + } + $result = json_decode((string)$raw, true); + if (!is_array($result) || empty($result['success'])) authCaptchaFailure('google'); + + $hostname = trim((string)getConfigVal('security.captcha.google.hostname', '')); + if ($hostname !== '' && !hash_equals(strtolower($hostname), strtolower((string)($result['hostname'] ?? '')))) { + authCaptchaFailure('google'); + } + if (getConfigVal('security.captcha.google.version', 'v2') === 'v3') { + $score = (float)($result['score'] ?? 0); + $minimum = max(0.0, min(1.0, (float)getConfigVal('security.captcha.google.v3_min_score', 0.5))); + if (($result['action'] ?? '') !== $operation || $score < $minimum) { + authCaptchaFailure('google'); + } + } +} + function jsonResponse(array $data, int $code = 200): never { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); @@ -593,15 +739,19 @@ function getArchiveDB(string $year, string $month, string $day): PDO { id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, username TEXT NOT NULL,color TEXT NOT NULL,message TEXT NOT NULL, message_type TEXT NOT NULL DEFAULT 'text',voice_file TEXT,voice_mime TEXT, - voice_duration REAL,created_at INTEGER NOT NULL,day_key TEXT NOT NULL + voice_duration REAL,voice_bitrate_kbps INTEGER,reply_to_id INTEGER, + reply_depth INTEGER NOT NULL DEFAULT 0,created_at INTEGER NOT NULL,day_key TEXT NOT NULL )"); ensureColumns($db, 'messages', [ 'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', + 'voice_bitrate_kbps' => 'INTEGER', 'reply_to_id' => 'INTEGER', + 'reply_depth' => 'INTEGER NOT NULL DEFAULT 0', ]); removeLegacyGroupSchema($db, false); $db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)"); return $db; } @@ -631,13 +781,15 @@ function archiveYesterdayIfNeeded(): void { $archive = getArchiveDB($year, $month, $day); $archive->beginTransaction(); $insert = $archive->prepare("INSERT OR IGNORE INTO messages - (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) - VALUES (?,?,?,?,?,?,?,?,?,?,?)"); + (id,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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)"); foreach ($rows as $row) { $insert->execute([ $row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'], - $row['voice_duration'], $row['created_at'], $row['day_key'], + $row['voice_duration'], $row['voice_bitrate_kbps'], $row['reply_to_id'], + $row['reply_depth'], $row['created_at'], $row['day_key'], ]); } $meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES (?,?)"); @@ -655,22 +807,28 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500) $includeVoice = (bool)featureValue($user, 'voice_archive', false); $rows = []; $db = getDB(); - $sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?"; + $sql = "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 m.user_id=? AND m.created_at>=?"; $params = [$user['id'], $cutoff]; - if (!$includeVoice) $sql .= " AND message_type='text'"; - if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; } - $stmt = $db->prepare($sql . " ORDER BY created_at DESC LIMIT ?"); + if (!$includeVoice) $sql .= " AND m.message_type='text'"; + if ($search !== '') { $sql .= " AND m.message LIKE ?"; $params[] = '%' . $search . '%'; } + $stmt = $db->prepare($sql . " ORDER BY m.created_at DESC LIMIT ?"); $params[] = $limit; $stmt->execute($params); $rows = $stmt->fetchAll(); foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) { $archive = getArchiveDB($year, $month, $day); - $archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?"; + $archiveSql = "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 m.user_id=? AND m.created_at>=?"; $archiveParams = [$user['id'], $cutoff]; - if (!$includeVoice) $archiveSql .= " AND message_type='text'"; - if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; } - $archiveStmt = $archive->prepare($archiveSql . " ORDER BY created_at DESC LIMIT ?"); + if (!$includeVoice) $archiveSql .= " AND m.message_type='text'"; + if ($search !== '') { $archiveSql .= " AND m.message LIKE ?"; $archiveParams[] = '%' . $search . '%'; } + $archiveStmt = $archive->prepare($archiveSql . " ORDER BY m.created_at DESC LIMIT ?"); $archiveParams[] = $limit; $archiveStmt->execute($archiveParams); array_push($rows, ...$archiveStmt->fetchAll()); diff --git a/config.json b/config.json index 42a9d92..e7277a5 100644 --- a/config.json +++ b/config.json @@ -10,6 +10,8 @@ "poll_max_backoff_ms": 15000, "messages_per_page": 100, "max_rendered_messages": 500, + "replies_enabled": true, + "reply_max_depth": 4, "session_lock_ip": true, "session_lock_cookie": true, "allow_guest": false @@ -57,7 +59,28 @@ "max_login_attempts": 10, "session_timeout_hours": 24, "bcrypt_cost": 10, - "allowed_origins": [] + "allowed_origins": [], + "captcha": { + "registration_enabled": true, + "login_enabled": true, + "honeypot": { + "enabled": true + }, + "internal": { + "enabled": true, + "difficulty": "easy", + "ttl_seconds": 300, + "max_attempts": 3 + }, + "google": { + "enabled": false, + "version": "v2", + "site_key": "", + "secret_key": "", + "v3_min_score": 0.5, + "hostname": "" + } + } }, "subscriptions": { "enabled": true diff --git a/embed-example.html b/embed-example.html index 9116571..9a7819a 100644 --- a/embed-example.html +++ b/embed-example.html @@ -6,7 +6,7 @@ CyberChat — Embed Example - +