- 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:
Ty Clifford
2026-06-09 10:59:58 -04:00
parent 88805057cc
commit eb57cde99f
14 changed files with 844 additions and 110 deletions
+38 -8
View File
@@ -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 ## Current Capabilities
### Chat and Interface ### Chat and Interface
- Authenticated public chat with text and voice messages - 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 - 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 - 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 - 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 ### Voice and Audio
- Browser microphone recording with a configurable duration and upload-size limit - 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 - WebM/Opus recording by default on supported browsers
- MP4/AAC and AAC recording fallback for Safari and iPhone - MP4/AAC and AAC recording fallback for Safari and iPhone
- Upload validation for WebM, WAV, MP4/M4A, and AAC - 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 - Mailgun-based email delivery through a reusable cURL mailer
- Tier-configurable email two-factor authentication - Tier-configurable email two-factor authentication
- Six-digit login challenges with expiration and attempt limits - 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 - Account view for email verification, 2FA, plan details, billing, and current feature entitlements
- Tier-configurable custom username colors - Tier-configurable custom username colors
- Administrator user creation, username/color/password editing, session termination, and deletion - 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 - Per-tier feature toggles and archive retention
- Current configurable entitlements: - Current configurable entitlements:
- Voice messages - Voice messages
- Voice bitrate
- Recording-status sending and viewing - Recording-status sending and viewing
- Automatic voice send - Automatic voice send
- Automatic voice play - Automatic voice play
@@ -189,6 +195,7 @@ The defaults are starting points. Plans, prices, and every listed entitlement ca
| Feature | Free | Plus | Pro | | Feature | Free | Plus | Pro |
|---|---:|---:|---:| |---|---:|---:|---:|
| Voice messages | Yes | Yes | Yes | | Voice messages | Yes | Yes | Yes |
| Voice bitrate | 64 kbps | 96 kbps | 128 kbps |
| Recording status send/view | No | Yes | Yes | | Recording status send/view | No | Yes | Yes |
| Automatic voice send | No | Yes | Yes | | Automatic voice send | No | Yes | Yes |
| Automatic voice play | 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.enabled` | Enable server-side FFmpeg conversion |
| `voice.transcoding.ffmpeg_path` | FFmpeg executable name or absolute path | | `voice.transcoding.ffmpeg_path` | FFmpeg executable name or absolute path |
| `voice.transcoding.profile` | `m4a_aac` or `mp4_h264_aac` | | `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.timeout_seconds` | Conversion timeout from 5 to 300 seconds |
| `voice.transcoding.fallback_to_source` | Keep the source file if conversion fails | | `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. - New uploads are converted; existing files are not rewritten.
- The original file can be retained automatically if FFmpeg fails. - 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`. The web server must serve `.webm`, `.wav`, `.aac`, `.m4a`, and `.mp4` with the MIME mappings shown in `.htaccess` and `nginx-example.conf`.
## Mailgun ## 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. 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:
- <https://developers.google.com/recaptcha/docs/verify>
- <https://developers.google.com/recaptcha/docs/v3>
## Polling and Message Ordering ## Polling and Message Ordering
CyberChat uses one polling worker per active chat view: 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 | | Archives | Browse days, inspect file sizes, filter/edit/delete messages, delete days |
| Users | Create users, edit credentials/colors, assign tiers, kick, delete | | Users | Create users, edit credentials/colors, assign tiers, kick, delete |
| Sessions | Inspect active/expired sessions and terminate one, expired, or all | | 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 | | Config | Edit and validate `config.json` directly |
## Configuration Families ## 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: `config.json` is editable through **Admin > Config**. The primary groups are:
- `app`: installation base URL - `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 - `archive`: archive enablement and directory
- `voice`: recording, upload, playback defaults, storage, and FFmpeg - `voice`: recording, upload, playback defaults, storage, and FFmpeg
- `database`: main SQLite path, archive template, and optional MySQL mirror - `database`: main SQLite path, archive template, and optional MySQL mirror
- `ui`: application title and subtitle - `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 - `subscriptions`: new-subscription visibility
- `mailgun`: verification and 2FA delivery - `mailgun`: verification and 2FA delivery
- `stripe`: Checkout, webhook, and Billing Portal settings - `stripe`: Checkout, webhook, and Billing Portal settings
@@ -392,12 +422,12 @@ The diagnostic endpoint exposes deployment details and should be restricted or r
## Embedding ## Embedding
```html ```html
<link rel="stylesheet" href="/chat/assets/css/cyberchat.css?v=20260609.3"> <link rel="stylesheet" href="/chat/assets/css/cyberchat.css?v=20260609.4">
<div id="my-chat" style="width:100%;height:600px"></div> <div id="my-chat" style="width:100%;height:600px"></div>
<script> <script>
window.CYBERCHAT_BASE = '/chat'; window.CYBERCHAT_BASE = '/chat';
</script> </script>
<script src="/chat/assets/js/cyberchat-app.js?v=20260609.6"></script> <script src="/chat/assets/js/cyberchat-app.js?v=20260609.7"></script>
<script> <script>
CyberChat.init('#my-chat'); CyberChat.init('#my-chat');
</script> </script>
+22 -5
View File
@@ -10,7 +10,7 @@
define('ROOT_DIR', __DIR__); define('ROOT_DIR', __DIR__);
define('CONFIG_FILE', ROOT_DIR . '/config.json'); 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 . '/bootstrap.php';
require_once ROOT_DIR . '/lib/admin_platform.php'; require_once ROOT_DIR . '/lib/admin_platform.php';
@@ -47,6 +47,9 @@ function ensureAdminMessageColumns(PDO $d): void {
'voice_file' => 'TEXT', 'voice_file' => 'TEXT',
'voice_mime' => 'TEXT', 'voice_mime' => 'TEXT',
'voice_duration' => 'REAL', 'voice_duration' => 'REAL',
'voice_bitrate_kbps' => 'INTEGER',
'reply_to_id' => 'INTEGER',
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
] as $name => $definition) { ] as $name => $definition) {
if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $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); ensureAdminMessageColumns($a);
removeLegacyGroupSchema($a, false); 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_user ON messages(user_id,created_at)");
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
return $a; return $a;
} }
@@ -84,6 +88,9 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
voice_file TEXT, voice_file TEXT,
voice_mime TEXT, voice_mime TEXT,
voice_duration REAL, voice_duration REAL,
voice_bitrate_kbps INTEGER,
reply_to_id INTEGER,
reply_depth INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
day_key TEXT NOT NULL day_key TEXT NOT NULL
)"); )");
@@ -94,6 +101,7 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
value TEXT 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_user ON messages(user_id,created_at)");
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
return $a; return $a;
} }
@@ -223,11 +231,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if ($existingFile === false) { if ($existingFile === false) {
$insert = $adb->prepare("INSERT INTO messages $insert = $adb->prepare("INSERT INTO messages
(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,
VALUES (?,?,?,?,?,?,?,?,?,?,?)"); voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
$insert->execute([ $insert->execute([
$row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'], $row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'],
$row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'], $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'] $row['created_at'], $row['day_key']
]); ]);
} }
@@ -1114,12 +1124,14 @@ td.actions{white-space:nowrap;width:1px}
<td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($msg['username']) ?></td> <td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($msg['username']) ?></td>
<td> <td>
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?> <?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s</div> <div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s
<?= !empty($msg['voice_bitrate_kbps']) ? ' · ' . (int)$msg['voice_bitrate_kbps'] . ' kbps' : '' ?></div>
<audio class="admin-audio" controls preload="metadata" <audio class="admin-audio" controls preload="metadata"
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio> src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
<?php else: ?> <?php else: ?>
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div> <div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($msg['reply_to_id'])): ?><div class="dim">Reply #<?= (int)$msg['reply_to_id'] ?> · depth <?= (int)$msg['reply_depth'] ?></div><?php endif; ?>
</td> </td>
<td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td> <td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
<td class="actions"> <td class="actions">
@@ -1392,12 +1404,14 @@ td.actions{white-space:nowrap;width:1px}
<td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px"><?= esc($msg['username']) ?></td> <td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px"><?= esc($msg['username']) ?></td>
<td> <td>
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?> <?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s</div> <div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s
<?= !empty($msg['voice_bitrate_kbps']) ? ' · ' . (int)$msg['voice_bitrate_kbps'] . ' kbps' : '' ?></div>
<audio class="admin-audio" controls preload="metadata" <audio class="admin-audio" controls preload="metadata"
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio> src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
<?php else: ?> <?php else: ?>
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div> <div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($msg['reply_to_id'])): ?><div class="dim">Reply #<?= (int)$msg['reply_to_id'] ?> · depth <?= (int)$msg['reply_depth'] ?></div><?php endif; ?>
</td> </td>
<td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td> <td class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
<td class="actions"> <td class="actions">
@@ -1724,6 +1738,8 @@ td.actions{white-space:nowrap;width:1px}
<tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr> <tr><td class="mono">chat.poll_timeout_ms</td><td class="dim">integer</td><td>Abort and retry a stalled poll after this many milliseconds</td></tr>
<tr><td class="mono">chat.poll_max_backoff_ms</td><td class="dim">integer</td><td>Maximum delay after repeated poll failures</td></tr> <tr><td class="mono">chat.poll_max_backoff_ms</td><td class="dim">integer</td><td>Maximum delay after repeated poll failures</td></tr>
<tr><td class="mono">chat.max_rendered_messages</td><td class="dim">integer</td><td>Maximum chat rows retained in the browser</td></tr> <tr><td class="mono">chat.max_rendered_messages</td><td class="dim">integer</td><td>Maximum chat rows retained in the browser</td></tr>
<tr><td class="mono">chat.replies_enabled</td><td class="dim">bool</td><td>Enable reply-to and threaded display</td></tr>
<tr><td class="mono">chat.reply_max_depth</td><td class="dim">integer</td><td>Maximum nested reply depth</td></tr>
<tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr> <tr><td class="mono">chat.session_lock_ip</td><td class="dim">bool</td><td>Lock session to IP</td></tr>
<tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr> <tr><td class="mono">voice.enabled</td><td class="dim">bool</td><td>Show voice recording controls</td></tr>
<tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr> <tr><td class="mono">voice.max_duration_seconds</td><td class="dim">integer</td><td>Maximum voice clip duration</td></tr>
@@ -1736,6 +1752,7 @@ td.actions{white-space:nowrap;width:1px}
<tr><td class="mono">voice.transcoding.timeout_seconds</td><td class="dim">integer</td><td>FFmpeg upload conversion timeout</td></tr> <tr><td class="mono">voice.transcoding.timeout_seconds</td><td class="dim">integer</td><td>FFmpeg upload conversion timeout</td></tr>
<tr><td class="mono">voice.transcoding.fallback_to_source</td><td class="dim">bool</td><td>Retain WebM/MP4 source when conversion fails</td></tr> <tr><td class="mono">voice.transcoding.fallback_to_source</td><td class="dim">bool</td><td>Retain WebM/MP4 source when conversion fails</td></tr>
<tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr> <tr><td class="mono">security.min_password_length</td><td class="dim">integer</td><td>Min password chars</td></tr>
<tr><td class="mono">security.captcha.*</td><td class="dim">object</td><td>Registration/login honeypot, internal, and Google CAPTCHA settings</td></tr>
<tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr> <tr><td class="mono">security.session_timeout_hours</td><td class="dim">integer</td><td>Session expiry in hours</td></tr>
<tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr> <tr><td class="mono">security.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (1014)</td></tr>
<tr><td class="mono">ui.app_title</td><td class="dim">string</td><td>Header title text</td></tr> <tr><td class="mono">ui.app_title</td><td class="dim">string</td><td>Header title text</td></tr>
+21 -2
View File
@@ -17,6 +17,12 @@ if ($action === 'export') {
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'username' => $row['username'], 'message' => $row['message'], 'username' => $row['username'], 'message' => $row['message'],
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']), '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'])) { if ($row['message_type'] === 'voice' && $canVoiceExport && !empty($row['voice_file'])) {
$item['voice_url'] = appBaseUrl() . '/' . voicePublicPath($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-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.csv"'); header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
$out = fopen('php://output', 'w'); $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, [ foreach ($export as $row) fputcsv($out, [
$row['id'], $row['username'], $row['message'], $row['type'], $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); fclose($out);
exit; 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_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_mime' => $row['voice_mime'] ?? null, 'voice_mime' => $row['voice_mime'] ?? null,
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : 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'], 'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'],
]; ];
}, $rows); }, $rows);
+8
View File
@@ -6,6 +6,7 @@ sendCorsHeaders();
$action = $_POST['action'] ?? $_GET['action'] ?? ''; $action = $_POST['action'] ?? $_GET['action'] ?? '';
match ($action) { match ($action) {
'captcha_challenge' => captchaChallenge(),
'register' => registerUser(), 'register' => registerUser(),
'login' => loginUser(), 'login' => loginUser(),
'verify_2fa' => verifyTwoFactor(), 'verify_2fa' => verifyTwoFactor(),
@@ -14,7 +15,13 @@ match ($action) {
default => jsonResponse(['error' => 'Unknown action'], 400), default => jsonResponse(['error' => 'Unknown action'], 400),
}; };
function captchaChallenge(): never {
$operation = trim((string)($_GET['operation'] ?? $_POST['operation'] ?? ''));
jsonResponse(createAuthCaptchaChallenge($operation));
}
function registerUser(): never { function registerUser(): never {
validateAuthCaptcha('register');
$db = getDB(); $db = getDB();
$username = trim((string)($_POST['username'] ?? '')); $username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? ''); $password = (string)($_POST['password'] ?? '');
@@ -44,6 +51,7 @@ function registerUser(): never {
} }
function loginUser(): never { function loginUser(): never {
validateAuthCaptcha('login');
$db = getDB(); $db = getDB();
$username = trim((string)($_POST['username'] ?? '')); $username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? ''); $password = (string)($_POST['password'] ?? '');
+14 -1
View File
@@ -12,9 +12,22 @@ jsonResponse([
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000, 'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
'poll_max_backoff_ms' => $config['chat']['poll_max_backoff_ms'] ?? 15000, 'poll_max_backoff_ms' => $config['chat']['poll_max_backoff_ms'] ?? 15000,
'max_rendered_messages' => $config['chat']['max_rendered_messages'] ?? 500, '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'] ?? [], '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'] ?? []], 'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []],
'voice' => [ 'voice' => [
'enabled' => $config['voice']['enabled'] ?? true, 'enabled' => $config['voice']['enabled'] ?? true,
+56 -41
View File
@@ -27,11 +27,49 @@ function messagePayload(array $row): array {
'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null, 'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_mime' => $row['voice_mime'] ?? null, 'voice_mime' => $row['voice_mime'] ?? null,
'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : 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'], 'created_at' => (int)$row['created_at'],
'day_key' => $row['day_key'] ?? dayKey(), '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 { function sendTextMessage(): never {
$user = authRequired(); $user = authRequired();
$message = trim((string)($_POST['message'] ?? '')); $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); if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
$db = getDB(); $db = getDB();
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key) [$replyId, $replyDepth] = resolveReply($db);
VALUES (?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([ $db->prepare("INSERT INTO messages
$user['id'], $user['username'], $user['color'], $message, dayKey(), (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(); $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']); trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([ jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
'id' => $messageId,
'user_id' => $user['id'],
'username' => $user['username'],
'color' => $user['color'],
'message' => $message,
'message_type' => 'text',
'created_at' => $created,
'day_key' => dayKey(),
])]);
} }
function sendVoiceMessage(): never { 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); 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(); $dir = voiceUploadDir();
ensureWritableDirectory($dir, 'Voice storage'); ensureWritableDirectory($dir, 'Voice storage');
$token = bin2hex(random_bytes(20)); $token = bin2hex(random_bytes(20));
@@ -118,21 +149,22 @@ function sendVoiceMessage(): never {
'audio/x-aac' => 'audio/aac', 'audio/x-aac' => 'audio/aac',
default => $mime, default => $mime,
}; };
$bitrate = max(48, min(320, (int)featureValue($user, 'voice_bitrate_kbps', 64)));
try { try {
$stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime); $stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime, $bitrate);
} catch (Throwable $e) { } catch (Throwable $e) {
if (is_file($sourcePath)) @unlink($sourcePath); if (is_file($sourcePath)) @unlink($sourcePath);
throw $e; throw $e;
} }
$filename = $stored['filename']; $filename = $stored['filename'];
$storedMime = $stored['mime']; $storedMime = $stored['mime'];
$db = getDB();
try { try {
$db->prepare("INSERT INTO messages $db->prepare("INSERT INTO messages
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) (user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
VALUES (?,?,?,?,'voice',?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([ 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]', $user['id'], $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, dayKey(), $filename, $storedMime, $duration, $bitrate, $replyId, $replyDepth, dayKey(),
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
deleteVoiceFile($filename); deleteVoiceFile($filename);
@@ -141,23 +173,8 @@ function sendVoiceMessage(): never {
$messageId = (int)$db->lastInsertId(); $messageId = (int)$db->lastInsertId();
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?") $db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
->execute([$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']); trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([ jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
'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(),
])]);
} }
function setRecordingStatus(): never { function setRecordingStatus(): never {
@@ -185,15 +202,13 @@ function pollMessages(): never {
$db = getDB(); $db = getDB();
if ($since === 0) { if ($since === 0) {
$stmt = $db->prepare("SELECT * FROM messages $stmt = $db->prepare(messageSelectSql('m.day_key=?') . " ORDER BY m.id DESC LIMIT ?");
WHERE day_key=? ORDER BY id DESC LIMIT ?");
$stmt->execute([dayKey(), $limit]); $stmt->execute([dayKey(), $limit]);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
usort($rows, 'compareMessagesChronologically'); usort($rows, 'compareMessagesChronologically');
$hasMore = false; $hasMore = false;
} else { } else {
$stmt = $db->prepare("SELECT * FROM messages $stmt = $db->prepare(messageSelectSql('m.day_key=? AND m.id>?') . " ORDER BY m.id LIMIT ?");
WHERE day_key=? AND id>? ORDER BY id LIMIT ?");
$stmt->execute([dayKey(), $since, $limit + 1]); $stmt->execute([dayKey(), $since, $limit + 1]);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
$hasMore = count($rows) > $limit; $hasMore = count($rows) > $limit;
+107 -1
View File
@@ -283,7 +283,7 @@ input, button { font-family: inherit; }
.cc-auth-panel { .cc-auth-panel {
width: 100%; width: 100%;
max-width: 340px; max-width: 380px;
background: var(--bg1); background: var(--bg1);
border: 1px solid var(--bd); border: 1px solid var(--bd);
border-radius: var(--r2); border-radius: var(--r2);
@@ -505,6 +505,50 @@ input, button { font-family: inherit; }
opacity: 1; 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 CHAT SCREEN
*/ */
@@ -604,6 +648,8 @@ input, button { font-family: inherit; }
} }
.cc-msg:hover { background: rgba(255,255,255,0.02); } .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 { .cc-msg-time {
font-size: 9px; font-size: 9px;
@@ -641,6 +687,37 @@ input, button { font-family: inherit; }
.cc-msg-own .cc-msg-text { color: #ddeeff; } .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 { .cc-voice-message {
display: inline-flex; display: inline-flex;
align-items: center; 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.send { border-color: var(--g); color: var(--g); }
.cc-voice-action:disabled { opacity: 0.35; cursor: not-allowed; } .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 */ /* Compose bar */
.cc-compose { .cc-compose {
display: flex; 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 { 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 time { color: var(--t1); font-size: 9px; }
.cc-archive-row audio { display: none; } .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-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 { display: flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--t0); font: 10px var(--mono); }
.cc-switch input { accent-color: var(--g); } .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-cyber-audio { min-width: 0; width: 100%; grid-template-columns: 26px 18px minmax(60px, 1fr) 64px 32px; }
.cc-compact-select { max-width: 145px; } .cc-compact-select { max-width: 145px; }
.cc-page { padding: 12px; } .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; }
} }
+275 -23
View File
@@ -13,6 +13,8 @@
}, },
loginChallenge: initialChallenge, loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '', 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, voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false, chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false,
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false } playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
@@ -20,7 +22,7 @@
let root; let root;
let wakeListenersBound = false; let wakeListenersBound = false;
const displayedFeatures = new Set([ 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', 'username_color', 'text_archive_days', 'text_export', 'voice_archive',
'voice_export', 'email_2fa' 'voice_export', 'email_2fa'
]); ]);
@@ -151,9 +153,11 @@
state.config = await api('config.php').catch(() => ({ state.config = await api('config.php').catch(() => ({
chat: { chat: {
max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000, 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' } ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
})); }));
if (!wakeListenersBound) { 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 ? `<div class="cc-captcha-trap" aria-hidden="true">
<label>Website<input id="${operation}-hp-website" tabindex="-1" autocomplete="off"></label>
<label><input id="${operation}-hp-contact" type="checkbox" tabindex="-1"> Contact me</label>
</div>` : ''}
<div class="cc-captcha-block">
${captcha.internal_enabled ? `<div class="cc-internal-captcha">
<label class="cc-field-label">HUMAN CHECK</label>
<div class="cc-captcha-row"><span id="${operation}-captcha-question">LOADING...</span>
<input class="cc-input" id="${operation}-captcha-answer" inputmode="numeric" autocomplete="off" aria-label="CAPTCHA answer">
<button class="cc-captcha-refresh" id="${operation}-captcha-refresh" type="button">NEW</button></div>
</div>` : ''}
${captcha.google_enabled && captcha.google_version === 'v2'
? `<div class="cc-google-captcha" id="${operation}-google-captcha"></div>` : ''}
${captcha.google_enabled && captcha.google_version === 'v3'
? '<div class="cc-captcha-note">Protected by reCAPTCHA v3</div>' : ''}
</div>`;
}
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) { function auth(tab) {
stopPoll(); stopPoll();
state.captcha.internal = {};
state.captcha.widgets = {};
const maxUser = state.config.chat?.max_username_length || 12; const maxUser = state.config.chat?.max_username_length || 12;
const minPass = state.config.security?.min_password_length || 4; const minPass = state.config.security?.min_password_length || 4;
$('#cc-body').innerHTML = `<div class="cc-auth-wrap"><div class="cc-auth-panel"> $('#cc-body').innerHTML = `<div class="cc-auth-wrap"><div class="cc-auth-panel">
@@ -207,11 +344,13 @@
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div> <div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div>
<div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div> <div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div>
<div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div> <div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div>
${authCaptchaMarkup('register')}
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div> <button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login"> <div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
<p class="cc-form-hint">Welcome back.</p> <p class="cc-form-hint">Welcome back.</p>
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div> <div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div>
<div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div> <div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div>
${authCaptchaMarkup('login')}
<button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div> <button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div>
<div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p> <div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p>
<div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div> <div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div>
@@ -221,11 +360,16 @@
$('#register').addEventListener('click', register); $('#register').addEventListener('click', register);
$('#login').addEventListener('click', login); $('#login').addEventListener('click', login);
if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge)); if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
void setupAuthCaptcha();
} }
function switchAuth(name) { function switchAuth(name) {
$$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === 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)); $$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name));
authError(''); authError('');
if (name === 'register' || name === 'login') {
if (!state.captcha.internal[name]) void loadInternalCaptcha(name);
renderGoogleCaptcha(name).catch(error => authError(error.message));
}
} }
function authError(message) { function authError(message) {
const item = $('#auth-error'); const item = $('#auth-error');
@@ -237,16 +381,29 @@
const password = $('#reg-pass').value; const password = $('#reg-pass').value;
if (!username || !password) return authError('Username and password are required'); if (!username || !password) return authError('Username and password are required');
if (password !== $('#reg-pass2').value) return authError('Passphrases do not match'); if (password !== $('#reg-pass2').value) return authError('Passphrases do not match');
const result = await api('auth.php', form({ action: 'register', username, password })); let request;
if (result.error) return authError(result.error); 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; state.user = result.user;
await enter(); await enter();
} }
async function login() { async function login() {
const result = await api('auth.php', form({ let request;
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value try {
})); request = await authForm('login', {
if (result.error) return authError(result.error); 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) { if (result.requires_2fa) {
state.loginChallenge = result.challenge; state.loginChallenge = result.challenge;
switchAuth('2fa'); switchAuth('2fa');
@@ -306,20 +463,51 @@
else accountView(); 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() { function chat() {
const max = state.config.chat?.max_message_length || 500; const max = state.config.chat?.max_message_length || 500;
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true); const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
state.reply = null;
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left"> $('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span> <span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div></div> <span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div></div>
<div class="cc-recording-indicator" id="recording" hidden></div> <div class="cc-recording-indicator" id="recording" hidden></div>
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div> <div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button> ${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s</span> <span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS</span>
${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''} ${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''}
${feature('auto_voice_play') ? `<label class="cc-voice-autoplay"><input id="auto-play" type="checkbox" ${state.voice.autoPlay ? 'checked' : ''}> AUTO PLAY</label>` : ''} ${feature('auto_voice_play') ? `<label class="cc-voice-autoplay"><input id="auto-play" type="checkbox" ${state.voice.autoPlay ? 'checked' : ''}> AUTO PLAY</label>` : ''}
<div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')} <div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')}
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''} <button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
<div class="cc-reply-context" id="reply-context" hidden><span>REPLYING TO <b id="reply-context-user"></b></span>
<span id="reply-context-text"></span><button id="reply-cancel" type="button">CANCEL</button></div>
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message..."> <div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`; <span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length); $('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
@@ -327,6 +515,7 @@
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
}); });
$('#send').addEventListener('click', sendText); $('#send').addEventListener('click', sendText);
$('#reply-cancel').addEventListener('click', clearReply);
$('#record')?.addEventListener('click', toggleVoice); $('#record')?.addEventListener('click', toggleVoice);
$('#voice-send')?.addEventListener('click', sendVoice); $('#voice-send')?.addEventListener('click', sendVoice);
$('#voice-discard')?.addEventListener('click', discardVoice); $('#voice-discard')?.addEventListener('click', discardVoice);
@@ -343,11 +532,20 @@
const input = $('#message'); const input = $('#message');
const message = input.value.trim(); const message = input.value.trim();
if (!message) return; if (!message) return;
const max = state.config.chat?.max_message_length || 500;
input.value = ''; 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.' })); .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); acknowledgeSentMessage(result.message);
clearReply();
requestPoll(); requestPoll();
} }
function startPoll() { function startPoll() {
@@ -471,7 +669,14 @@
function normalizeMessage(message) { function normalizeMessage(message) {
const id = Number(message?.id); const id = Number(message?.id);
if (!Number.isSafeInteger(id) || id < 1) return null; 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) { function acknowledgeSentMessage(message) {
const acknowledged = normalizeMessage(message); const acknowledged = normalizeMessage(message);
@@ -489,7 +694,9 @@
return JSON.stringify([ return JSON.stringify([
message.user_id ?? null, message.username, message.color, message.message, message.user_id ?? null, message.username, message.color, message.message,
message.message_type, message.voice_url, message.voice_mime, 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) { function compareMessagesChronologically(a, b) {
@@ -562,16 +769,39 @@
} }
function createMessageElement(message) { function createMessageElement(message) {
const id = message.id; 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'); 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.id = String(id);
item.dataset.renderKey = messageRenderKey(message); item.dataset.renderKey = messageRenderKey(message);
const content = message.message_type === 'voice' && message.voice_url const content = message.message_type === 'voice' && message.voice_url
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>` ? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps} KBPS` : ''}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
: linkify(esc(message.message)); : 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
? `<button class="cc-reply-ref" type="button" data-parent-id="${message.reply_to_id}">
<b>${esc(message.reply_username || `#${message.reply_to_id}`)}</b>
<span>${esc(String(parentText).slice(0, 120))}</span></button>`
: '';
const replyAction = state.config.chat?.replies_enabled !== false && depth < maxDepth
? '<button class="cc-msg-reply" type="button">REPLY</button>' : '';
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span> item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span> <span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
<span class="cc-msg-sep">&gt;</span><span class="cc-msg-text">${content}</span>`; <span class="cc-msg-sep">&gt;</span><span class="cc-msg-text">${replyReference}${content}</span>${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); bindAudioPlayers(item);
return item; return item;
} }
@@ -644,15 +874,25 @@
]; ];
let recorder = null; let recorder = null;
let mime = ''; let mime = '';
const audioBitsPerSecond = voiceBitrate() * 1000;
for (const candidate of candidates) { for (const candidate of candidates) {
if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue; if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue;
try { try {
recorder = new MediaRecorder(stream, { mimeType: candidate }); recorder = new MediaRecorder(stream, { mimeType: candidate, audioBitsPerSecond });
mime = candidate; mime = candidate;
break; 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.stream = stream;
state.voice.chunks = []; state.voice.chunks = [];
state.voice.started = Date.now(); state.voice.started = Date.now();
@@ -677,7 +917,9 @@
function voiceTimer() { function voiceTimer() {
const max = state.config.voice?.max_duration_seconds || 60; const max = state.config.voice?.max_duration_seconds || 60;
const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max); 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); if (elapsed >= max) stopVoice(false);
} }
function stopVoice(discard) { function stopVoice(discard) {
@@ -706,7 +948,7 @@
$('#voice-preview').src = state.voice.url; $('#voice-preview').src = state.voice.url;
$('#voice-preview').load(); $('#voice-preview').load();
$('#voice-review').hidden = false; $('#voice-review').hidden = false;
$('#voice-status').textContent = 'READY ' + duration(length); $('#voice-status').textContent = `READY ${duration(length)} · ${voiceBitrate()} KBPS`;
} }
function discardVoice() { function discardVoice() {
if (state.voice.recorder) stopVoice(true); if (state.voice.recorder) stopVoice(true);
@@ -716,6 +958,9 @@
state.voice.mime = ''; state.voice.mime = '';
state.voice.duration = 0; state.voice.duration = 0;
if ($('#voice-review')) $('#voice-review').hidden = true; 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() { async function sendVoice() {
if (!state.voice.blob || state.voice.sending) return; if (!state.voice.blob || state.voice.sending) return;
@@ -723,6 +968,7 @@
const body = new FormData(); const body = new FormData();
body.append('action', 'send_voice'); body.append('action', 'send_voice');
body.append('duration', state.voice.duration.toFixed(2)); 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' const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a'
: state.voice.mime.includes('aac') ? 'voice.aac' : state.voice.mime.includes('aac') ? 'voice.aac'
: state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm'; : state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm';
@@ -733,6 +979,7 @@
if (result.error) return toast(result.error); if (result.error) return toast(result.error);
acknowledgeSentMessage(result.message); acknowledgeSentMessage(result.message);
discardVoice(); discardVoice();
clearReply();
requestPoll(); requestPoll();
} }
@@ -753,8 +1000,13 @@
function archiveRow(message) { function archiveRow(message) {
const content = message.message_type === 'voice' && message.voice_url const content = message.message_type === 'voice' && message.voice_url
? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message); ? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message);
const reply = message.reply_to_id
? `<div class="cc-archive-reply">Reply to ${esc(message.reply_username || `#${message.reply_to_id}`)}:
${esc(message.reply_message_type === 'voice' ? '[Voice clip]' : (message.reply_message || 'Original unavailable'))}</div>`
: '';
return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time> return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`; <span class="cc-type-tag">${esc(message.message_type)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps}k` : ''}</span>
<div>${reply}${content}</div></div>`;
} }
async function accountView() { async function accountView() {
+175 -17
View File
@@ -191,13 +191,18 @@ function removeLegacyGroupSchema(PDO $db, bool $mainDatabase): void {
voice_file TEXT, voice_file TEXT,
voice_mime TEXT, voice_mime TEXT,
voice_duration REAL, 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')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
day_key TEXT NOT NULL" . day_key TEXT NOT NULL" .
($mainDatabase ? ", FOREIGN KEY(user_id) REFERENCES users(id)" : '') . " ($mainDatabase ? ", FOREIGN KEY(user_id) REFERENCES users(id)" : '') . "
)"); )");
$db->exec("INSERT INTO messages_without_groups $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) (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key 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"); FROM messages WHERE group_id IS NULL");
$db->exec("DROP TABLE messages"); $db->exec("DROP TABLE messages");
$db->exec("ALTER TABLE messages_without_groups RENAME TO messages"); $db->exec("ALTER TABLE messages_without_groups RENAME TO messages");
@@ -268,6 +273,9 @@ function initDB(PDO $db): void {
voice_file TEXT, voice_file TEXT,
voice_mime TEXT, voice_mime TEXT,
voice_duration REAL, 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')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
day_key TEXT NOT NULL, day_key TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
@@ -277,6 +285,9 @@ function initDB(PDO $db): void {
'voice_file' => 'TEXT', 'voice_file' => 'TEXT',
'voice_mime' => 'TEXT', 'voice_mime' => 'TEXT',
'voice_duration' => 'REAL', 'voice_duration' => 'REAL',
'voice_bitrate_kbps' => 'INTEGER',
'reply_to_id' => 'INTEGER',
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
]); ]);
removeLegacyGroupSchema($db, true); removeLegacyGroupSchema($db, true);
@@ -339,6 +350,14 @@ function initDB(PDO $db): void {
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE 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'); $recordingColumns = tableColumns($db, 'recording_status');
if ($recordingColumns && !isset($recordingColumns['channel_key'])) { if ($recordingColumns && !isset($recordingColumns['channel_key'])) {
$db->exec("DROP TABLE recording_status"); $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_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_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_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_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_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"); $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL");
seedPlans($db); seedPlans($db);
@@ -383,7 +404,8 @@ function seedPlans(PDO $db): void {
'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.', 'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.',
'price' => 0, 'sort' => 0, 'price' => 0, 'sort' => 0,
'features' => [ '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, 'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, '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.', 'name' => 'Plus', 'description' => 'Premium voice, identity, security, and 90-day personal archives.',
'price' => 499, 'sort' => 10, 'price' => 499, 'sort' => 10,
'features' => [ '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, 'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 90,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, '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.', 'name' => 'Pro', 'description' => 'Premium voice, security, and one year of personal archives.',
'price' => 999, 'sort' => 20, 'price' => 999, 'sort' => 20,
'features' => [ '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, 'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 365,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
], ],
@@ -512,6 +536,128 @@ function clientIP(): string {
return '0.0.0.0'; 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 { function jsonResponse(array $data, int $code = 200): never {
http_response_code($code); http_response_code($code);
header('Content-Type: application/json; charset=utf-8'); 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, id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
username TEXT NOT NULL,color TEXT NOT NULL,message TEXT 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, 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', [ ensureColumns($db, 'messages', [
'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', '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); removeLegacyGroupSchema($db, false);
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)"); $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_user ON messages(user_id,created_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
return $db; return $db;
} }
@@ -631,13 +781,15 @@ function archiveYesterdayIfNeeded(): void {
$archive = getArchiveDB($year, $month, $day); $archive = getArchiveDB($year, $month, $day);
$archive->beginTransaction(); $archive->beginTransaction();
$insert = $archive->prepare("INSERT OR IGNORE INTO messages $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) (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
VALUES (?,?,?,?,?,?,?,?,?,?,?)"); voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
foreach ($rows as $row) { foreach ($rows as $row) {
$insert->execute([ $insert->execute([
$row['id'], $row['user_id'], $row['username'], $row['color'], $row['id'], $row['user_id'], $row['username'], $row['color'],
$row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'], $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 (?,?)"); $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); $includeVoice = (bool)featureValue($user, 'voice_archive', false);
$rows = []; $rows = [];
$db = getDB(); $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]; $params = [$user['id'], $cutoff];
if (!$includeVoice) $sql .= " AND message_type='text'"; if (!$includeVoice) $sql .= " AND m.message_type='text'";
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; } if ($search !== '') { $sql .= " AND m.message LIKE ?"; $params[] = '%' . $search . '%'; }
$stmt = $db->prepare($sql . " ORDER BY created_at DESC LIMIT ?"); $stmt = $db->prepare($sql . " ORDER BY m.created_at DESC LIMIT ?");
$params[] = $limit; $params[] = $limit;
$stmt->execute($params); $stmt->execute($params);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) { foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
$archive = getArchiveDB($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]; $archiveParams = [$user['id'], $cutoff];
if (!$includeVoice) $archiveSql .= " AND message_type='text'"; if (!$includeVoice) $archiveSql .= " AND m.message_type='text'";
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; } if ($search !== '') { $archiveSql .= " AND m.message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
$archiveStmt = $archive->prepare($archiveSql . " ORDER BY created_at DESC LIMIT ?"); $archiveStmt = $archive->prepare($archiveSql . " ORDER BY m.created_at DESC LIMIT ?");
$archiveParams[] = $limit; $archiveParams[] = $limit;
$archiveStmt->execute($archiveParams); $archiveStmt->execute($archiveParams);
array_push($rows, ...$archiveStmt->fetchAll()); array_push($rows, ...$archiveStmt->fetchAll());
+24 -1
View File
@@ -10,6 +10,8 @@
"poll_max_backoff_ms": 15000, "poll_max_backoff_ms": 15000,
"messages_per_page": 100, "messages_per_page": 100,
"max_rendered_messages": 500, "max_rendered_messages": 500,
"replies_enabled": true,
"reply_max_depth": 4,
"session_lock_ip": true, "session_lock_ip": true,
"session_lock_cookie": true, "session_lock_cookie": true,
"allow_guest": false "allow_guest": false
@@ -57,7 +59,28 @@
"max_login_attempts": 10, "max_login_attempts": 10,
"session_timeout_hours": 24, "session_timeout_hours": 24,
"bcrypt_cost": 10, "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": { "subscriptions": {
"enabled": true "enabled": true
+4 -4
View File
@@ -6,7 +6,7 @@
<title>CyberChat — Embed Example</title> <title>CyberChat — Embed Example</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.4">
<style> <style>
html, body { html, body {
height: auto; height: auto;
@@ -88,13 +88,13 @@
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span> <span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span> <span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260609.3"</span><span class="kw">&gt;</span> <span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260609.4"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span> <span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span> <span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span> <span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.6"</span><span class="kw">&gt;&lt;/script&gt;</span> <span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.7"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span> <span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span> window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>); CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-app.js?v=20260609.6"></script> <script src="assets/js/cyberchat-app.js?v=20260609.7"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here'); CyberChat.init('#chat-here');
+2 -2
View File
@@ -10,14 +10,14 @@
<title>Chat @ TyClifford.com</title> <title>Chat @ TyClifford.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.4">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-app.js?v=20260609.6"></script> <script src="assets/js/cyberchat-app.js?v=20260609.7"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app'); CyberChat.init('#app');
+94 -3
View File
@@ -4,8 +4,33 @@ function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') { if ($action === 'save_platform_services') {
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac'); $voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac');
if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = 'm4a_aac'; if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = 'm4a_aac';
$captchaVersion = (string)($_POST['captcha_google_version'] ?? 'v2');
if (!in_array($captchaVersion, ['v2', 'v3'], true)) $captchaVersion = 'v2';
$captchaDifficulty = (string)($_POST['captcha_internal_difficulty'] ?? 'easy');
if (!in_array($captchaDifficulty, ['easy', 'medium'], true)) $captchaDifficulty = 'easy';
$captchaGoogleEnabled = !empty($_POST['captcha_google_enabled']);
$captchaGoogleSiteKey = trim((string)($_POST['captcha_google_site_key'] ?? ''));
$captchaGoogleSecretKey = trim((string)($_POST['captcha_google_secret_key'] ?? ''));
if ($captchaGoogleEnabled && ($captchaGoogleSiteKey === '' || $captchaGoogleSecretKey === '')) {
throw new RuntimeException('Google reCAPTCHA requires both a site key and secret key.');
}
setConfigValues([ setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']), 'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'chat.replies_enabled' => !empty($_POST['replies_enabled']),
'chat.reply_max_depth' => max(1, min(20, (int)($_POST['reply_max_depth'] ?? 4))),
'security.captcha.registration_enabled' => !empty($_POST['captcha_registration_enabled']),
'security.captcha.login_enabled' => !empty($_POST['captcha_login_enabled']),
'security.captcha.honeypot.enabled' => !empty($_POST['captcha_honeypot_enabled']),
'security.captcha.internal.enabled' => !empty($_POST['captcha_internal_enabled']),
'security.captcha.internal.difficulty' => $captchaDifficulty,
'security.captcha.internal.ttl_seconds' => max(60, min(1800, (int)($_POST['captcha_internal_ttl'] ?? 300))),
'security.captcha.internal.max_attempts' => max(1, min(10, (int)($_POST['captcha_internal_attempts'] ?? 3))),
'security.captcha.google.enabled' => $captchaGoogleEnabled,
'security.captcha.google.version' => $captchaVersion,
'security.captcha.google.site_key' => $captchaGoogleSiteKey,
'security.captcha.google.secret_key' => $captchaGoogleSecretKey,
'security.captcha.google.v3_min_score' => max(0.0, min(1.0, (float)($_POST['captcha_google_v3_score'] ?? 0.5))),
'security.captcha.google.hostname' => trim((string)($_POST['captcha_google_hostname'] ?? '')),
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')), 'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')), 'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')), 'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
@@ -64,6 +89,9 @@ function handleAdminPlatformAction(string $action): ?string {
foreach ($boolFeatures as $key) { foreach ($boolFeatures as $key) {
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]); $upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
} }
$upsertFeature->execute([$id, 'voice_bitrate_kbps', json_encode(
max(48, min(320, (int)($plan['features']['voice_bitrate_kbps'] ?? 64)))
)]);
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]); $upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
} }
$db->commit(); $db->commit();
@@ -87,7 +115,8 @@ function handleAdminPlatformAction(string $action): ?string {
->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]); ->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]);
$id = (int)$db->lastInsertId(); $id = (int)$db->lastInsertId();
$features = [ $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, 'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, 'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
]; ];
@@ -124,7 +153,7 @@ function renderAdminPlatformTab(): void {
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events $events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll(); WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
$featureKeys = [ $featureKeys = [
'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', 'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'voice_export', 'email_2fa',
]; ];
?> ?>
@@ -144,6 +173,64 @@ function renderAdminPlatformTab(): void {
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>> <div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div> <span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// REPLY THREADS</span></div><div class="panel-body">
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="replies_enabled" <?= getConfigVal('chat.replies_enabled', true) ? 'checked' : '' ?>>
<span>Enable reply-to controls for text and voice messages</span></label>
<div class="field"><label>Maximum Reply Depth</label><input type="number" min="1" max="20"
name="reply_max_depth" value="<?= (int)getConfigVal('chat.reply_max_depth', 4) ?>"></div>
</div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// REGISTRATION / LOGIN CAPTCHA</span></div><div class="panel-body">
<p class="dim">CAPTCHA checks apply only to initial registration and password login. Email 2FA verification is not challenged.</p>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_registration_enabled"
<?= getConfigVal('security.captcha.registration_enabled', true) ? 'checked' : '' ?>><span>Protect registration</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_login_enabled"
<?= getConfigVal('security.captcha.login_enabled', true) ? 'checked' : '' ?>><span>Protect login</span></label>
</div>
<div class="form-row">
<label class="cb-row"><input type="checkbox" name="captcha_honeypot_enabled"
<?= getConfigVal('security.captcha.honeypot.enabled', true) ? 'checked' : '' ?>>
<span>Invisible honeypot fields that deny bot-like submissions</span></label>
<label class="cb-row"><input type="checkbox" name="captcha_internal_enabled"
<?= getConfigVal('security.captcha.internal.enabled', true) ? 'checked' : '' ?>>
<span>Internal arithmetic challenge</span></label>
</div>
<div class="form-row3">
<div class="field"><label>Internal Difficulty</label><select name="captcha_internal_difficulty">
<?php $captchaDifficulty = getConfigVal('security.captcha.internal.difficulty', 'easy'); ?>
<option value="easy" <?= $captchaDifficulty === 'easy' ? 'selected' : '' ?>>Easy (1-9)</option>
<option value="medium" <?= $captchaDifficulty === 'medium' ? 'selected' : '' ?>>Medium (1-25)</option>
</select></div>
<div class="field"><label>Challenge TTL (seconds)</label><input type="number" min="60" max="1800"
name="captcha_internal_ttl" value="<?= (int)getConfigVal('security.captcha.internal.ttl_seconds', 300) ?>"></div>
<div class="field"><label>Maximum Attempts</label><input type="number" min="1" max="10"
name="captcha_internal_attempts" value="<?= (int)getConfigVal('security.captcha.internal.max_attempts', 3) ?>"></div>
</div>
<label class="cb-row"><input type="checkbox" name="captcha_google_enabled"
<?= getConfigVal('security.captcha.google.enabled', false) ? 'checked' : '' ?>>
<span>Also require Google reCAPTCHA</span></label>
<div class="form-row3">
<div class="field"><label>Google Version</label><select name="captcha_google_version">
<?php $captchaVersion = getConfigVal('security.captcha.google.version', 'v2'); ?>
<option value="v2" <?= $captchaVersion === 'v2' ? 'selected' : '' ?>>v2 checkbox</option>
<option value="v3" <?= $captchaVersion === 'v3' ? 'selected' : '' ?>>v3 score</option>
</select></div>
<div class="field"><label>Site Key</label><input name="captcha_google_site_key"
value="<?= esc(getConfigVal('security.captcha.google.site_key', '')) ?>"></div>
<div class="field"><label>Secret Key</label><input type="password" name="captcha_google_secret_key"
value="<?= esc(getConfigVal('security.captcha.google.secret_key', '')) ?>"></div>
</div>
<div class="form-row">
<div class="field"><label>v3 Minimum Score</label><input type="number" min="0" max="1" step="0.1"
name="captcha_google_v3_score" value="<?= esc(getConfigVal('security.captcha.google.v3_min_score', 0.5)) ?>"></div>
<div class="field"><label>Expected Hostname (optional)</label><input name="captcha_google_hostname"
value="<?= esc(getConfigVal('security.captcha.google.hostname', '')) ?>" placeholder="chat.example.com"></div>
</div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body"> <div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
<div class="form-row3"> <div class="form-row3">
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div> <div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
@@ -178,7 +265,7 @@ function renderAdminPlatformTab(): void {
<option value="m4a_aac" <?= $voiceProfile === 'm4a_aac' ? 'selected' : '' ?>>M4A / AAC-LC (recommended)</option> <option value="m4a_aac" <?= $voiceProfile === 'm4a_aac' ? 'selected' : '' ?>>M4A / AAC-LC (recommended)</option>
<option value="mp4_h264_aac" <?= $voiceProfile === 'mp4_h264_aac' ? 'selected' : '' ?>>MP4 / H.264 + AAC</option> <option value="mp4_h264_aac" <?= $voiceProfile === 'mp4_h264_aac' ? 'selected' : '' ?>>MP4 / H.264 + AAC</option>
</select></div> </select></div>
<div class="field"><label>Audio Bitrate (kbps)</label><input type="number" min="48" max="320" <div class="field"><label>Fallback Audio Bitrate (kbps)</label><input type="number" min="48" max="320"
name="voice_audio_bitrate" value="<?= (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128) ?>"></div> name="voice_audio_bitrate" value="<?= (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128) ?>"></div>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -230,6 +317,10 @@ function renderAdminPlatformTab(): void {
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number" <div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="30" min="30"
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div> name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div>
<?php elseif ($key === 'voice_bitrate_kbps'): ?>
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="48" max="320"
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 64) ?>"></div>
<?php else: ?> <?php else: ?>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" <label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]"
<?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label> <?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label>
+4 -2
View File
@@ -72,14 +72,16 @@ function runVoiceTranscodeCommand(array $command, int $timeoutSeconds): array {
function transcodeVoiceRecording( function transcodeVoiceRecording(
string $sourcePath, string $sourcePath,
string $sourceFilename, string $sourceFilename,
string $sourceMime string $sourceMime,
?int $bitrateOverride = null
): array { ): array {
if (!getConfigVal('voice.transcoding.enabled', false)) { if (!getConfigVal('voice.transcoding.enabled', false)) {
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false]; return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
} }
$profile = voiceTranscodingProfile(); $profile = voiceTranscodingProfile();
$bitrate = max(48, min(320, (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128))); $bitrate = max(48, min(320, $bitrateOverride
?? (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128)));
$timeout = max(5, min(300, (int)getConfigVal('voice.transcoding.timeout_seconds', 45))); $timeout = max(5, min(300, (int)getConfigVal('voice.transcoding.timeout_seconds', 45)));
$fallback = (bool)getConfigVal('voice.transcoding.fallback_to_source', true); $fallback = (bool)getConfigVal('voice.transcoding.fallback_to_source', true);
$token = pathinfo($sourceFilename, PATHINFO_FILENAME); $token = pathinfo($sourceFilename, PATHINFO_FILENAME);