- 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:
@@ -1,12 +1,13 @@
|
||||
# CyberChat 2.1.0
|
||||
# CyberChat 2.2.0
|
||||
|
||||
CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with text and voice messaging, configurable service tiers, Stripe subscriptions, Mailgun email verification, private archives, resilient queued polling, an administrator dashboard, and optional FFmpeg and MySQL/MariaDB integrations.
|
||||
CyberChat 2.2.0 is the current baseline release. It is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with text and voice messaging, configurable service tiers, Stripe subscriptions, Mailgun email verification, private archives, resilient queued polling, an administrator dashboard, and optional FFmpeg and MySQL/MariaDB integrations.
|
||||
|
||||
## Current Capabilities
|
||||
|
||||
### Chat and Interface
|
||||
|
||||
- Authenticated public chat with text and voice messages
|
||||
- Reply-to controls for text and voice with configurable thread depth
|
||||
- Chronological message reconciliation with the newest message at the bottom
|
||||
- Single-worker cursor polling with catch-up batches, deduplication, request timeouts, retry backoff, and immediate wake-up after sending
|
||||
- Poll recovery when the browser returns online or the tab becomes visible
|
||||
@@ -22,6 +23,7 @@ CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQL
|
||||
### Voice and Audio
|
||||
|
||||
- Browser microphone recording with a configurable duration and upload-size limit
|
||||
- Per-tier recording and FFmpeg output bitrate from 48 to 320 kbps
|
||||
- WebM/Opus recording by default on supported browsers
|
||||
- MP4/AAC and AAC recording fallback for Safari and iPhone
|
||||
- Upload validation for WebM, WAV, MP4/M4A, and AAC
|
||||
@@ -55,6 +57,9 @@ CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQL
|
||||
- Mailgun-based email delivery through a reusable cURL mailer
|
||||
- Tier-configurable email two-factor authentication
|
||||
- Six-digit login challenges with expiration and attempt limits
|
||||
- Registration/login-only internal arithmetic CAPTCHA
|
||||
- Invisible registration/login honeypot fields that reject bot-like submissions
|
||||
- Optional Google reCAPTCHA v2 checkbox or v3 score/action verification
|
||||
- Account view for email verification, 2FA, plan details, billing, and current feature entitlements
|
||||
- Tier-configurable custom username colors
|
||||
- Administrator user creation, username/color/password editing, session termination, and deletion
|
||||
@@ -67,6 +72,7 @@ CyberChat 2.1.0 is the current baseline release. It is a framework-free PHP, SQL
|
||||
- Per-tier feature toggles and archive retention
|
||||
- Current configurable entitlements:
|
||||
- Voice messages
|
||||
- Voice bitrate
|
||||
- Recording-status sending and viewing
|
||||
- Automatic voice send
|
||||
- Automatic voice play
|
||||
@@ -189,6 +195,7 @@ The defaults are starting points. Plans, prices, and every listed entitlement ca
|
||||
| Feature | Free | Plus | Pro |
|
||||
|---|---:|---:|---:|
|
||||
| Voice messages | Yes | Yes | Yes |
|
||||
| Voice bitrate | 64 kbps | 96 kbps | 128 kbps |
|
||||
| Recording status send/view | No | Yes | Yes |
|
||||
| Automatic voice send | No | Yes | Yes |
|
||||
| Automatic voice play | No | Yes | Yes |
|
||||
@@ -217,7 +224,7 @@ Important settings:
|
||||
| `voice.transcoding.enabled` | Enable server-side FFmpeg conversion |
|
||||
| `voice.transcoding.ffmpeg_path` | FFmpeg executable name or absolute path |
|
||||
| `voice.transcoding.profile` | `m4a_aac` or `mp4_h264_aac` |
|
||||
| `voice.transcoding.audio_bitrate_kbps` | AAC bitrate from 48 to 320 kbps |
|
||||
| `voice.transcoding.audio_bitrate_kbps` | Fallback AAC bitrate when no tier override is supplied |
|
||||
| `voice.transcoding.timeout_seconds` | Conversion timeout from 5 to 300 seconds |
|
||||
| `voice.transcoding.fallback_to_source` | Keep the source file if conversion fails |
|
||||
|
||||
@@ -239,6 +246,8 @@ When FFmpeg is enabled:
|
||||
- New uploads are converted; existing files are not rewritten.
|
||||
- The original file can be retained automatically if FFmpeg fails.
|
||||
|
||||
The authenticated user's `voice_bitrate_kbps` tier entitlement controls the browser MediaRecorder target and overrides the FFmpeg fallback bitrate for new clips.
|
||||
|
||||
The web server must serve `.webm`, `.wav`, `.aac`, `.m4a`, and `.mp4` with the MIME mappings shown in `.htaccess` and `nginx-example.conf`.
|
||||
|
||||
## Mailgun
|
||||
@@ -305,6 +314,27 @@ User archive behavior:
|
||||
|
||||
Voice files remain in `uploads/voice/` while their metadata moves between live and archive databases. Administrator deletion actions remove the associated recording file.
|
||||
|
||||
Reply IDs, reply depth, parent previews, and voice bitrate metadata are retained in daily archives and personal JSON/CSV exports.
|
||||
|
||||
## CAPTCHA
|
||||
|
||||
CAPTCHA checks are scoped to registration and initial password login. Email 2FA code verification, chat messages, account updates, and dashboard actions do not invoke CAPTCHA.
|
||||
|
||||
Dashboard controls under **Plans & Platform > Registration / Login CAPTCHA** configure:
|
||||
|
||||
- Registration and login protection independently
|
||||
- Invisible honeypot protection
|
||||
- Internal arithmetic challenge difficulty, expiration, and attempt limit
|
||||
- Google reCAPTCHA v2 checkbox or v3 score mode
|
||||
- Google site key, secret key, expected hostname, and v3 minimum score
|
||||
|
||||
Google tokens are verified server-side through the Siteverify API. For v3, CyberChat also validates the expected `register` or `login` action and the configured score threshold.
|
||||
|
||||
Google references:
|
||||
|
||||
- <https://developers.google.com/recaptcha/docs/verify>
|
||||
- <https://developers.google.com/recaptcha/docs/v3>
|
||||
|
||||
## Polling and Message Ordering
|
||||
|
||||
CyberChat uses one polling worker per active chat view:
|
||||
@@ -331,7 +361,7 @@ This design avoids overlapping poll requests, duplicate voice players, and tempo
|
||||
| Archives | Browse days, inspect file sizes, filter/edit/delete messages, delete days |
|
||||
| Users | Create users, edit credentials/colors, assign tiers, kick, delete |
|
||||
| Sessions | Inspect active/expired sessions and terminate one, expired, or all |
|
||||
| Plans & Platform | Plans, pricing, entitlements, Stripe, Mailgun, FFmpeg, MySQL, statistics |
|
||||
| Plans & Platform | Plans, pricing, bitrate, replies, CAPTCHA, Stripe, Mailgun, FFmpeg, MySQL, statistics |
|
||||
| Config | Edit and validate `config.json` directly |
|
||||
|
||||
## Configuration Families
|
||||
@@ -339,12 +369,12 @@ This design avoids overlapping poll requests, duplicate voice players, and tempo
|
||||
`config.json` is editable through **Admin > Config**. The primary groups are:
|
||||
|
||||
- `app`: installation base URL
|
||||
- `chat`: text limits, polling, browser buffer, and session restrictions
|
||||
- `chat`: text limits, polling, browser buffer, replies, and session restrictions
|
||||
- `archive`: archive enablement and directory
|
||||
- `voice`: recording, upload, playback defaults, storage, and FFmpeg
|
||||
- `database`: main SQLite path, archive template, and optional MySQL mirror
|
||||
- `ui`: application title and subtitle
|
||||
- `security`: password, session, bcrypt, and CORS settings
|
||||
- `security`: password, session, bcrypt, CORS, and registration/login CAPTCHA settings
|
||||
- `subscriptions`: new-subscription visibility
|
||||
- `mailgun`: verification and 2FA delivery
|
||||
- `stripe`: Checkout, webhook, and Billing Portal settings
|
||||
@@ -392,12 +422,12 @@ The diagnostic endpoint exposes deployment details and should be restricted or r
|
||||
## Embedding
|
||||
|
||||
```html
|
||||
<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>
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '/chat';
|
||||
</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>
|
||||
CyberChat.init('#my-chat');
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
define('ROOT_DIR', __DIR__);
|
||||
define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
||||
define('ADMIN_VERSION', '2.1.0');
|
||||
define('ADMIN_VERSION', '2.2.0');
|
||||
require_once ROOT_DIR . '/bootstrap.php';
|
||||
require_once ROOT_DIR . '/lib/admin_platform.php';
|
||||
|
||||
@@ -47,6 +47,9 @@ function ensureAdminMessageColumns(PDO $d): void {
|
||||
'voice_file' => 'TEXT',
|
||||
'voice_mime' => 'TEXT',
|
||||
'voice_duration' => 'REAL',
|
||||
'voice_bitrate_kbps' => 'INTEGER',
|
||||
'reply_to_id' => 'INTEGER',
|
||||
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
|
||||
] as $name => $definition) {
|
||||
if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition");
|
||||
}
|
||||
@@ -62,6 +65,7 @@ function archiveDB(string $year, string $month, string $day): ?PDO {
|
||||
ensureAdminMessageColumns($a);
|
||||
removeLegacyGroupSchema($a, false);
|
||||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
|
||||
return $a;
|
||||
}
|
||||
|
||||
@@ -84,6 +88,9 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
|
||||
voice_file TEXT,
|
||||
voice_mime TEXT,
|
||||
voice_duration REAL,
|
||||
voice_bitrate_kbps INTEGER,
|
||||
reply_to_id INTEGER,
|
||||
reply_depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
day_key TEXT NOT NULL
|
||||
)");
|
||||
@@ -94,6 +101,7 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
|
||||
value TEXT
|
||||
)");
|
||||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||||
$a->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
|
||||
return $a;
|
||||
}
|
||||
|
||||
@@ -223,11 +231,13 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
if ($existingFile === false) {
|
||||
$insert = $adb->prepare("INSERT INTO messages
|
||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
|
||||
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||
$insert->execute([
|
||||
$row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'],
|
||||
$row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'],
|
||||
$row['voice_bitrate_kbps'], $row['reply_to_id'], $row['reply_depth'],
|
||||
$row['created_at'], $row['day_key']
|
||||
]);
|
||||
}
|
||||
@@ -1114,12 +1124,14 @@ td.actions{white-space:nowrap;width:1px}
|
||||
<td style="color:<?= esc($msg['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($msg['username']) ?></td>
|
||||
<td>
|
||||
<?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"
|
||||
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
||||
<?php else: ?>
|
||||
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
|
||||
<?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 class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
|
||||
<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>
|
||||
<?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"
|
||||
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
||||
<?php else: ?>
|
||||
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
|
||||
<?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 class="dim" style="white-space:nowrap"><?= fmtTime((int)$msg['created_at']) ?></td>
|
||||
<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_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.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">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>
|
||||
@@ -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.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.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.bcrypt_cost</td><td class="dim">integer</td><td>bcrypt work factor (10–14)</td></tr>
|
||||
<tr><td class="mono">ui.app_title</td><td class="dim">string</td><td>Header title text</td></tr>
|
||||
|
||||
+21
-2
@@ -17,6 +17,12 @@ if ($action === 'export') {
|
||||
'id' => (int)$row['id'],
|
||||
'username' => $row['username'], 'message' => $row['message'],
|
||||
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']),
|
||||
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
|
||||
'reply_depth' => (int)($row['reply_depth'] ?? 0),
|
||||
'reply_username' => $row['reply_username'] ?? null,
|
||||
'reply_message' => $row['reply_message'] ?? null,
|
||||
'reply_message_type' => $row['reply_message_type'] ?? null,
|
||||
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
|
||||
];
|
||||
if ($row['message_type'] === 'voice' && $canVoiceExport && !empty($row['voice_file'])) {
|
||||
$item['voice_url'] = appBaseUrl() . '/' . voicePublicPath($row['voice_file']);
|
||||
@@ -28,10 +34,17 @@ if ($action === 'export') {
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['id', 'username', 'message', 'type', 'created_at', 'voice_url']);
|
||||
fputcsv($out, [
|
||||
'id', 'username', 'message', 'type', 'created_at', 'reply_to_id',
|
||||
'reply_depth', 'reply_username', 'reply_message', 'reply_message_type',
|
||||
'voice_bitrate_kbps', 'voice_url',
|
||||
]);
|
||||
foreach ($export as $row) fputcsv($out, [
|
||||
$row['id'], $row['username'], $row['message'], $row['type'],
|
||||
$row['created_at'], $row['voice_url'] ?? '',
|
||||
$row['created_at'], $row['reply_to_id'] ?? '', $row['reply_depth'],
|
||||
$row['reply_username'] ?? '', $row['reply_message'] ?? '',
|
||||
$row['reply_message_type'] ?? '', $row['voice_bitrate_kbps'] ?? '',
|
||||
$row['voice_url'] ?? '',
|
||||
]);
|
||||
fclose($out);
|
||||
exit;
|
||||
@@ -49,6 +62,12 @@ $payload = array_map(function(array $row): array {
|
||||
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
|
||||
'voice_mime' => $row['voice_mime'] ?? null,
|
||||
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null,
|
||||
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
|
||||
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
|
||||
'reply_depth' => (int)($row['reply_depth'] ?? 0),
|
||||
'reply_username' => $row['reply_username'] ?? null,
|
||||
'reply_message' => $row['reply_message'] ?? null,
|
||||
'reply_message_type' => $row['reply_message_type'] ?? null,
|
||||
'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
@@ -6,6 +6,7 @@ sendCorsHeaders();
|
||||
|
||||
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||
match ($action) {
|
||||
'captcha_challenge' => captchaChallenge(),
|
||||
'register' => registerUser(),
|
||||
'login' => loginUser(),
|
||||
'verify_2fa' => verifyTwoFactor(),
|
||||
@@ -14,7 +15,13 @@ match ($action) {
|
||||
default => jsonResponse(['error' => 'Unknown action'], 400),
|
||||
};
|
||||
|
||||
function captchaChallenge(): never {
|
||||
$operation = trim((string)($_GET['operation'] ?? $_POST['operation'] ?? ''));
|
||||
jsonResponse(createAuthCaptchaChallenge($operation));
|
||||
}
|
||||
|
||||
function registerUser(): never {
|
||||
validateAuthCaptcha('register');
|
||||
$db = getDB();
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
@@ -44,6 +51,7 @@ function registerUser(): never {
|
||||
}
|
||||
|
||||
function loginUser(): never {
|
||||
validateAuthCaptcha('login');
|
||||
$db = getDB();
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
|
||||
+14
-1
@@ -12,9 +12,22 @@ jsonResponse([
|
||||
'poll_timeout_ms' => $config['chat']['poll_timeout_ms'] ?? 12000,
|
||||
'poll_max_backoff_ms' => $config['chat']['poll_max_backoff_ms'] ?? 15000,
|
||||
'max_rendered_messages' => $config['chat']['max_rendered_messages'] ?? 500,
|
||||
'replies_enabled' => $config['chat']['replies_enabled'] ?? true,
|
||||
'reply_max_depth' => $config['chat']['reply_max_depth'] ?? 4,
|
||||
],
|
||||
'ui' => $config['ui'] ?? [],
|
||||
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
|
||||
'security' => [
|
||||
'min_password_length' => $config['security']['min_password_length'] ?? 4,
|
||||
'captcha' => [
|
||||
'registration_enabled' => $config['security']['captcha']['registration_enabled'] ?? false,
|
||||
'login_enabled' => $config['security']['captcha']['login_enabled'] ?? false,
|
||||
'honeypot_enabled' => $config['security']['captcha']['honeypot']['enabled'] ?? true,
|
||||
'internal_enabled' => $config['security']['captcha']['internal']['enabled'] ?? false,
|
||||
'google_enabled' => $config['security']['captcha']['google']['enabled'] ?? false,
|
||||
'google_version' => $config['security']['captcha']['google']['version'] ?? 'v2',
|
||||
'google_site_key' => $config['security']['captcha']['google']['site_key'] ?? '',
|
||||
],
|
||||
],
|
||||
'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []],
|
||||
'voice' => [
|
||||
'enabled' => $config['voice']['enabled'] ?? true,
|
||||
|
||||
+56
-41
@@ -27,11 +27,49 @@ function messagePayload(array $row): array {
|
||||
'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
|
||||
'voice_mime' => $row['voice_mime'] ?? null,
|
||||
'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : null,
|
||||
'voice_bitrate_kbps' => isset($row['voice_bitrate_kbps']) ? (int)$row['voice_bitrate_kbps'] : null,
|
||||
'reply_to_id' => !empty($row['reply_to_id']) ? (int)$row['reply_to_id'] : null,
|
||||
'reply_depth' => isset($row['reply_depth']) ? (int)$row['reply_depth'] : 0,
|
||||
'reply_username' => $row['reply_username'] ?? null,
|
||||
'reply_message' => $row['reply_message'] ?? null,
|
||||
'reply_message_type' => $row['reply_message_type'] ?? null,
|
||||
'created_at' => (int)$row['created_at'],
|
||||
'day_key' => $row['day_key'] ?? dayKey(),
|
||||
];
|
||||
}
|
||||
|
||||
function messageSelectSql(string $where): string {
|
||||
return "SELECT m.*,p.username AS reply_username,p.message AS reply_message,
|
||||
p.message_type AS reply_message_type
|
||||
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id WHERE $where";
|
||||
}
|
||||
|
||||
function messageById(PDO $db, int $id): array {
|
||||
$stmt = $db->prepare(messageSelectSql('m.id=?'));
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) throw new RuntimeException('Stored message could not be loaded.');
|
||||
return $row;
|
||||
}
|
||||
|
||||
function resolveReply(PDO $db): array {
|
||||
$replyId = max(0, (int)($_POST['reply_to_id'] ?? 0));
|
||||
if ($replyId === 0) return [null, 0];
|
||||
if (!getConfigVal('chat.replies_enabled', true)) {
|
||||
jsonResponse(['error' => 'Replies are disabled'], 403);
|
||||
}
|
||||
$maxDepth = max(1, min(20, (int)getConfigVal('chat.reply_max_depth', 4)));
|
||||
$stmt = $db->prepare("SELECT id,reply_depth FROM messages WHERE id=? AND day_key=?");
|
||||
$stmt->execute([$replyId, dayKey()]);
|
||||
$parent = $stmt->fetch();
|
||||
if (!$parent) jsonResponse(['error' => 'The message being replied to is unavailable'], 404);
|
||||
$depth = (int)$parent['reply_depth'] + 1;
|
||||
if ($depth > $maxDepth) {
|
||||
jsonResponse(['error' => "Reply depth limit reached (max $maxDepth)"], 400);
|
||||
}
|
||||
return [$replyId, $depth];
|
||||
}
|
||||
|
||||
function sendTextMessage(): never {
|
||||
$user = authRequired();
|
||||
$message = trim((string)($_POST['message'] ?? ''));
|
||||
@@ -40,25 +78,16 @@ function sendTextMessage(): never {
|
||||
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
|
||||
|
||||
$db = getDB();
|
||||
$db->prepare("INSERT INTO messages (user_id,username,color,message,created_at,day_key)
|
||||
VALUES (?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
|
||||
$user['id'], $user['username'], $user['color'], $message, dayKey(),
|
||||
[$replyId, $replyDepth] = resolveReply($db);
|
||||
$db->prepare("INSERT INTO messages
|
||||
(user_id,username,color,message,reply_to_id,reply_depth,created_at,day_key)
|
||||
VALUES (?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
|
||||
$user['id'], $user['username'], $user['color'], $message,
|
||||
$replyId, $replyDepth, dayKey(),
|
||||
]);
|
||||
$messageId = (int)$db->lastInsertId();
|
||||
$createdStmt = $db->prepare("SELECT created_at FROM messages WHERE id=?");
|
||||
$createdStmt->execute([$messageId]);
|
||||
$created = (int)$createdStmt->fetchColumn();
|
||||
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||
'id' => $messageId,
|
||||
'user_id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'color' => $user['color'],
|
||||
'message' => $message,
|
||||
'message_type' => 'text',
|
||||
'created_at' => $created,
|
||||
'day_key' => dayKey(),
|
||||
])]);
|
||||
jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
|
||||
}
|
||||
|
||||
function sendVoiceMessage(): never {
|
||||
@@ -102,6 +131,8 @@ function sendVoiceMessage(): never {
|
||||
];
|
||||
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WebM, MP4/M4A, AAC, or WAV'], 400);
|
||||
|
||||
$db = getDB();
|
||||
[$replyId, $replyDepth] = resolveReply($db);
|
||||
$dir = voiceUploadDir();
|
||||
ensureWritableDirectory($dir, 'Voice storage');
|
||||
$token = bin2hex(random_bytes(20));
|
||||
@@ -118,21 +149,22 @@ function sendVoiceMessage(): never {
|
||||
'audio/x-aac' => 'audio/aac',
|
||||
default => $mime,
|
||||
};
|
||||
$bitrate = max(48, min(320, (int)featureValue($user, 'voice_bitrate_kbps', 64)));
|
||||
try {
|
||||
$stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime);
|
||||
$stored = transcodeVoiceRecording($sourcePath, $sourceFilename, $sourceMime, $bitrate);
|
||||
} catch (Throwable $e) {
|
||||
if (is_file($sourcePath)) @unlink($sourcePath);
|
||||
throw $e;
|
||||
}
|
||||
$filename = $stored['filename'];
|
||||
$storedMime = $stored['mime'];
|
||||
$db = getDB();
|
||||
try {
|
||||
$db->prepare("INSERT INTO messages
|
||||
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||
VALUES (?,?,?,?,'voice',?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
|
||||
(user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
|
||||
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
|
||||
VALUES (?,?,?,?,'voice',?,?,?,?,?,?,CAST(strftime('%s','now') AS INTEGER),?)")->execute([
|
||||
$user['id'], $user['username'], $user['color'], '[Voice clip]',
|
||||
$filename, $storedMime, $duration, dayKey(),
|
||||
$filename, $storedMime, $duration, $bitrate, $replyId, $replyDepth, dayKey(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
deleteVoiceFile($filename);
|
||||
@@ -141,23 +173,8 @@ function sendVoiceMessage(): never {
|
||||
$messageId = (int)$db->lastInsertId();
|
||||
$db->prepare("DELETE FROM recording_status WHERE channel_key='public' AND user_id=?")
|
||||
->execute([$user['id']]);
|
||||
$createdStmt = $db->prepare("SELECT created_at FROM messages WHERE id=?");
|
||||
$createdStmt->execute([$messageId]);
|
||||
$created = (int)$createdStmt->fetchColumn();
|
||||
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
|
||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||
'id' => $messageId,
|
||||
'user_id' => $user['id'],
|
||||
'username' => $user['username'],
|
||||
'color' => $user['color'],
|
||||
'message' => '[Voice clip]',
|
||||
'message_type' => 'voice',
|
||||
'voice_file' => $filename,
|
||||
'voice_mime' => $storedMime,
|
||||
'voice_duration' => $duration,
|
||||
'created_at' => $created,
|
||||
'day_key' => dayKey(),
|
||||
])]);
|
||||
jsonResponse(['success' => true, 'message' => messagePayload(messageById($db, $messageId))]);
|
||||
}
|
||||
|
||||
function setRecordingStatus(): never {
|
||||
@@ -185,15 +202,13 @@ function pollMessages(): never {
|
||||
$db = getDB();
|
||||
|
||||
if ($since === 0) {
|
||||
$stmt = $db->prepare("SELECT * FROM messages
|
||||
WHERE day_key=? ORDER BY id DESC LIMIT ?");
|
||||
$stmt = $db->prepare(messageSelectSql('m.day_key=?') . " ORDER BY m.id DESC LIMIT ?");
|
||||
$stmt->execute([dayKey(), $limit]);
|
||||
$rows = $stmt->fetchAll();
|
||||
usort($rows, 'compareMessagesChronologically');
|
||||
$hasMore = false;
|
||||
} else {
|
||||
$stmt = $db->prepare("SELECT * FROM messages
|
||||
WHERE day_key=? AND id>? ORDER BY id LIMIT ?");
|
||||
$stmt = $db->prepare(messageSelectSql('m.day_key=? AND m.id>?') . " ORDER BY m.id LIMIT ?");
|
||||
$stmt->execute([dayKey(), $since, $limit + 1]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$hasMore = count($rows) > $limit;
|
||||
|
||||
+107
-1
@@ -283,7 +283,7 @@ input, button { font-family: inherit; }
|
||||
|
||||
.cc-auth-panel {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
max-width: 380px;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r2);
|
||||
@@ -505,6 +505,50 @@ input, button { font-family: inherit; }
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cc-captcha-trap {
|
||||
position: absolute !important;
|
||||
left: -10000px !important;
|
||||
top: auto !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.cc-captcha-block {
|
||||
margin: 4px 0 13px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--bd);
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
.cc-captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, auto) 1fr auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--g);
|
||||
font: 11px var(--mono);
|
||||
}
|
||||
|
||||
.cc-captcha-row .cc-input {
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.cc-captcha-refresh {
|
||||
height: 31px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--b);
|
||||
background: transparent;
|
||||
color: var(--b);
|
||||
font: 8px var(--mono);
|
||||
}
|
||||
|
||||
.cc-google-captcha { margin-top: 10px; min-height: 78px; }
|
||||
.cc-captcha-note { margin-top: 7px; color: var(--t1); font: 8px var(--mono); }
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
CHAT SCREEN
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
@@ -604,6 +648,8 @@ input, button { font-family: inherit; }
|
||||
}
|
||||
|
||||
.cc-msg:hover { background: rgba(255,255,255,0.02); }
|
||||
.cc-msg-thread { padding-left: calc(12px + var(--cc-reply-indent, 0px)); }
|
||||
.cc-msg-focus { background: rgba(0,184,255,0.12); box-shadow: inset 2px 0 0 var(--b); }
|
||||
|
||||
.cc-msg-time {
|
||||
font-size: 9px;
|
||||
@@ -641,6 +687,37 @@ input, button { font-family: inherit; }
|
||||
|
||||
.cc-msg-own .cc-msg-text { color: #ddeeff; }
|
||||
|
||||
.cc-reply-ref {
|
||||
display: flex;
|
||||
max-width: min(100%, 470px);
|
||||
margin: 0 0 3px;
|
||||
padding: 3px 6px;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
border-left: 2px solid var(--v);
|
||||
background: rgba(191,95,255,0.07);
|
||||
color: var(--t1);
|
||||
font: 9px var(--mono);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cc-reply-ref b { color: var(--v); flex-shrink: 0; }
|
||||
.cc-reply-ref span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.cc-msg-reply {
|
||||
align-self: center;
|
||||
margin-left: 7px;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--t2);
|
||||
font: 8px var(--mono);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cc-msg:hover .cc-msg-reply,
|
||||
.cc-msg-reply:focus { opacity: 1; border-color: var(--bd); color: var(--b); }
|
||||
|
||||
.cc-voice-message {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1040,6 +1117,28 @@ input, button { font-family: inherit; }
|
||||
.cc-voice-action.send { border-color: var(--g); color: var(--g); }
|
||||
.cc-voice-action:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
|
||||
.cc-reply-context {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid rgba(191,95,255,0.35);
|
||||
background: rgba(191,95,255,0.07);
|
||||
color: var(--t1);
|
||||
font: 8px var(--mono);
|
||||
}
|
||||
|
||||
.cc-reply-context[hidden] { display: none; }
|
||||
.cc-reply-context b { color: var(--v); }
|
||||
.cc-reply-context > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cc-reply-context button {
|
||||
border: 1px solid var(--r);
|
||||
background: transparent;
|
||||
color: var(--r);
|
||||
font: 8px var(--mono);
|
||||
}
|
||||
|
||||
/* Compose bar */
|
||||
.cc-compose {
|
||||
display: flex;
|
||||
@@ -1184,6 +1283,7 @@ html, body {
|
||||
.cc-archive-row { display: grid; grid-template-columns: 145px 55px 1fr; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--bd); background: var(--bg1); font: 11px var(--mono); }
|
||||
.cc-archive-row time { color: var(--t1); font-size: 9px; }
|
||||
.cc-archive-row audio { display: none; }
|
||||
.cc-archive-reply { margin-bottom: 5px; padding-left: 6px; border-left: 2px solid var(--v); color: var(--t1); font-size: 9px; }
|
||||
.cc-type-tag { color: var(--o); font-size: 8px; text-transform: uppercase; }
|
||||
.cc-switch { display: flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--t0); font: 10px var(--mono); }
|
||||
.cc-switch input { accent-color: var(--g); }
|
||||
@@ -1203,4 +1303,10 @@ html, body {
|
||||
.cc-cyber-audio { min-width: 0; width: 100%; grid-template-columns: 26px 18px minmax(60px, 1fr) 64px 32px; }
|
||||
.cc-compact-select { max-width: 145px; }
|
||||
.cc-page { padding: 12px; }
|
||||
.cc-msg-reply { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.cc-google-captcha { width: 268px; overflow: hidden; }
|
||||
.cc-google-captcha > div { transform: scale(.88); transform-origin: left top; }
|
||||
}
|
||||
|
||||
+275
-23
@@ -13,6 +13,8 @@
|
||||
},
|
||||
loginChallenge: initialChallenge,
|
||||
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||
reply: null,
|
||||
captcha: { internal: {}, widgets: {}, scriptPromise: null },
|
||||
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
||||
chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false,
|
||||
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
|
||||
@@ -20,7 +22,7 @@
|
||||
let root;
|
||||
let wakeListenersBound = false;
|
||||
const displayedFeatures = new Set([
|
||||
'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play',
|
||||
'voice_messages', 'voice_bitrate_kbps', 'recording_status', 'auto_voice_send', 'auto_voice_play',
|
||||
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
|
||||
'voice_export', 'email_2fa'
|
||||
]);
|
||||
@@ -151,9 +153,11 @@
|
||||
state.config = await api('config.php').catch(() => ({
|
||||
chat: {
|
||||
max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000,
|
||||
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500
|
||||
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500,
|
||||
replies_enabled: true, reply_max_depth: 4
|
||||
},
|
||||
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
|
||||
security: { min_password_length: 4, captcha: {} },
|
||||
voice: { enabled: true, max_duration_seconds: 60 },
|
||||
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
||||
}));
|
||||
if (!wakeListenersBound) {
|
||||
@@ -192,8 +196,141 @@
|
||||
});
|
||||
}
|
||||
|
||||
function authCaptchaEnabled(operation) {
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
return operation === 'register'
|
||||
? captcha.registration_enabled === true
|
||||
: captcha.login_enabled === true;
|
||||
}
|
||||
function authCaptchaMarkup(operation) {
|
||||
if (!authCaptchaEnabled(operation)) return '';
|
||||
const captcha = state.config.security?.captcha || {};
|
||||
return `${captcha.honeypot_enabled ? `<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) {
|
||||
stopPoll();
|
||||
state.captcha.internal = {};
|
||||
state.captcha.widgets = {};
|
||||
const maxUser = state.config.chat?.max_username_length || 12;
|
||||
const minPass = state.config.security?.min_password_length || 4;
|
||||
$('#cc-body').innerHTML = `<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">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>
|
||||
${authCaptchaMarkup('register')}
|
||||
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
|
||||
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
|
||||
<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">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>
|
||||
<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>
|
||||
@@ -221,11 +360,16 @@
|
||||
$('#register').addEventListener('click', register);
|
||||
$('#login').addEventListener('click', login);
|
||||
if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
|
||||
void setupAuthCaptcha();
|
||||
}
|
||||
function switchAuth(name) {
|
||||
$$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name));
|
||||
$$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name));
|
||||
authError('');
|
||||
if (name === 'register' || name === 'login') {
|
||||
if (!state.captcha.internal[name]) void loadInternalCaptcha(name);
|
||||
renderGoogleCaptcha(name).catch(error => authError(error.message));
|
||||
}
|
||||
}
|
||||
function authError(message) {
|
||||
const item = $('#auth-error');
|
||||
@@ -237,16 +381,29 @@
|
||||
const password = $('#reg-pass').value;
|
||||
if (!username || !password) return authError('Username and password are required');
|
||||
if (password !== $('#reg-pass2').value) return authError('Passphrases do not match');
|
||||
const result = await api('auth.php', form({ action: 'register', username, password }));
|
||||
if (result.error) return authError(result.error);
|
||||
let request;
|
||||
try { request = await authForm('register', { action: 'register', username, password }); }
|
||||
catch (error) { return authError(error.message); }
|
||||
const result = await api('auth.php', request);
|
||||
if (result.error) {
|
||||
refreshAuthCaptcha('register');
|
||||
return authError(result.error);
|
||||
}
|
||||
state.user = result.user;
|
||||
await enter();
|
||||
}
|
||||
async function login() {
|
||||
const result = await api('auth.php', form({
|
||||
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
|
||||
}));
|
||||
if (result.error) return authError(result.error);
|
||||
let request;
|
||||
try {
|
||||
request = await authForm('login', {
|
||||
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
|
||||
});
|
||||
} catch (error) { return authError(error.message); }
|
||||
const result = await api('auth.php', request);
|
||||
if (result.error) {
|
||||
refreshAuthCaptcha('login');
|
||||
return authError(result.error);
|
||||
}
|
||||
if (result.requires_2fa) {
|
||||
state.loginChallenge = result.challenge;
|
||||
switchAuth('2fa');
|
||||
@@ -306,20 +463,51 @@
|
||||
else accountView();
|
||||
}
|
||||
|
||||
function voiceBitrate() {
|
||||
return Math.max(48, Math.min(320, Number(feature('voice_bitrate_kbps', 64)) || 64));
|
||||
}
|
||||
function replyPreviewText(message) {
|
||||
if (!message) return '';
|
||||
return message.message_type === 'voice' ? '[Voice clip]' : String(message.message || '').slice(0, 120);
|
||||
}
|
||||
function setReply(message) {
|
||||
state.reply = {
|
||||
id: Number(message.id),
|
||||
username: message.username,
|
||||
message: replyPreviewText(message),
|
||||
depth: Number(message.reply_depth) || 0,
|
||||
};
|
||||
const bar = $('#reply-context');
|
||||
if (bar) {
|
||||
bar.hidden = false;
|
||||
$('#reply-context-user').textContent = state.reply.username;
|
||||
$('#reply-context-text').textContent = state.reply.message;
|
||||
}
|
||||
$('#message')?.focus();
|
||||
}
|
||||
function clearReply() {
|
||||
state.reply = null;
|
||||
const bar = $('#reply-context');
|
||||
if (bar) bar.hidden = true;
|
||||
}
|
||||
|
||||
function chat() {
|
||||
const max = state.config.chat?.max_message_length || 500;
|
||||
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
|
||||
state.reply = null;
|
||||
$('#cc-view').innerHTML = `<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-plan-badge">${esc(state.user.plan.name)}</span></div></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>
|
||||
${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_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')}
|
||||
<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...">
|
||||
<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);
|
||||
@@ -327,6 +515,7 @@
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
|
||||
});
|
||||
$('#send').addEventListener('click', sendText);
|
||||
$('#reply-cancel').addEventListener('click', clearReply);
|
||||
$('#record')?.addEventListener('click', toggleVoice);
|
||||
$('#voice-send')?.addEventListener('click', sendVoice);
|
||||
$('#voice-discard')?.addEventListener('click', discardVoice);
|
||||
@@ -343,11 +532,20 @@
|
||||
const input = $('#message');
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
const max = state.config.chat?.max_message_length || 500;
|
||||
input.value = '';
|
||||
const result = await api('messages.php', form({ action: 'send', message }))
|
||||
$('#count').textContent = max;
|
||||
const result = await api('messages.php', form({
|
||||
action: 'send', message, reply_to_id: state.reply?.id || ''
|
||||
}))
|
||||
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
||||
if (result.error) { input.value = message; return toast(result.error); }
|
||||
if (result.error) {
|
||||
input.value = message;
|
||||
$('#count').textContent = max - message.length;
|
||||
return toast(result.error);
|
||||
}
|
||||
acknowledgeSentMessage(result.message);
|
||||
clearReply();
|
||||
requestPoll();
|
||||
}
|
||||
function startPoll() {
|
||||
@@ -471,7 +669,14 @@
|
||||
function normalizeMessage(message) {
|
||||
const id = Number(message?.id);
|
||||
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||
return { ...message, id };
|
||||
const replyToId = Number(message?.reply_to_id);
|
||||
return {
|
||||
...message,
|
||||
id,
|
||||
reply_to_id: Number.isSafeInteger(replyToId) && replyToId > 0 ? replyToId : null,
|
||||
reply_depth: Math.max(0, Number(message?.reply_depth) || 0),
|
||||
voice_bitrate_kbps: Number(message?.voice_bitrate_kbps) || null,
|
||||
};
|
||||
}
|
||||
function acknowledgeSentMessage(message) {
|
||||
const acknowledged = normalizeMessage(message);
|
||||
@@ -489,7 +694,9 @@
|
||||
return JSON.stringify([
|
||||
message.user_id ?? null, message.username, message.color, message.message,
|
||||
message.message_type, message.voice_url, message.voice_mime,
|
||||
message.voice_duration, message.created_at
|
||||
message.voice_duration, message.voice_bitrate_kbps, message.reply_to_id,
|
||||
message.reply_depth, message.reply_username, message.reply_message,
|
||||
message.reply_message_type, message.created_at
|
||||
]);
|
||||
}
|
||||
function compareMessagesChronologically(a, b) {
|
||||
@@ -562,16 +769,39 @@
|
||||
}
|
||||
function createMessageElement(message) {
|
||||
const id = message.id;
|
||||
const depth = Math.max(0, Number(message.reply_depth) || 0);
|
||||
const maxDepth = Math.max(1, Number(state.config.chat?.reply_max_depth) || 4);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + ' cc-msg-new';
|
||||
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '')
|
||||
+ (depth ? ' cc-msg-thread' : '') + ' cc-msg-new';
|
||||
item.style.setProperty('--cc-reply-indent', `${Math.min(depth, 6) * 12}px`);
|
||||
item.dataset.id = String(id);
|
||||
item.dataset.renderKey = messageRenderKey(message);
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? `<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));
|
||||
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>
|
||||
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
||||
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
||||
<span class="cc-msg-sep">></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);
|
||||
return item;
|
||||
}
|
||||
@@ -644,15 +874,25 @@
|
||||
];
|
||||
let recorder = null;
|
||||
let mime = '';
|
||||
const audioBitsPerSecond = voiceBitrate() * 1000;
|
||||
for (const candidate of candidates) {
|
||||
if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue;
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, { mimeType: candidate });
|
||||
recorder = new MediaRecorder(stream, { mimeType: candidate, audioBitsPerSecond });
|
||||
mime = candidate;
|
||||
break;
|
||||
} catch {}
|
||||
} catch {
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, { mimeType: candidate });
|
||||
mime = candidate;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!recorder) {
|
||||
try { recorder = new MediaRecorder(stream, { audioBitsPerSecond }); }
|
||||
catch { recorder = new MediaRecorder(stream); }
|
||||
}
|
||||
if (!recorder) recorder = new MediaRecorder(stream);
|
||||
state.voice.stream = stream;
|
||||
state.voice.chunks = [];
|
||||
state.voice.started = Date.now();
|
||||
@@ -677,7 +917,9 @@
|
||||
function voiceTimer() {
|
||||
const max = state.config.voice?.max_duration_seconds || 60;
|
||||
const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max);
|
||||
if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`;
|
||||
if ($('#voice-status')) {
|
||||
$('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)} · ${voiceBitrate()} KBPS`;
|
||||
}
|
||||
if (elapsed >= max) stopVoice(false);
|
||||
}
|
||||
function stopVoice(discard) {
|
||||
@@ -706,7 +948,7 @@
|
||||
$('#voice-preview').src = state.voice.url;
|
||||
$('#voice-preview').load();
|
||||
$('#voice-review').hidden = false;
|
||||
$('#voice-status').textContent = 'READY ' + duration(length);
|
||||
$('#voice-status').textContent = `READY ${duration(length)} · ${voiceBitrate()} KBPS`;
|
||||
}
|
||||
function discardVoice() {
|
||||
if (state.voice.recorder) stopVoice(true);
|
||||
@@ -716,6 +958,9 @@
|
||||
state.voice.mime = '';
|
||||
state.voice.duration = 0;
|
||||
if ($('#voice-review')) $('#voice-review').hidden = true;
|
||||
if ($('#voice-status') && !state.voice.recorder) {
|
||||
$('#voice-status').textContent = `VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS`;
|
||||
}
|
||||
}
|
||||
async function sendVoice() {
|
||||
if (!state.voice.blob || state.voice.sending) return;
|
||||
@@ -723,6 +968,7 @@
|
||||
const body = new FormData();
|
||||
body.append('action', 'send_voice');
|
||||
body.append('duration', state.voice.duration.toFixed(2));
|
||||
body.append('reply_to_id', state.reply?.id || '');
|
||||
const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a'
|
||||
: state.voice.mime.includes('aac') ? 'voice.aac'
|
||||
: state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm';
|
||||
@@ -733,6 +979,7 @@
|
||||
if (result.error) return toast(result.error);
|
||||
acknowledgeSentMessage(result.message);
|
||||
discardVoice();
|
||||
clearReply();
|
||||
requestPoll();
|
||||
}
|
||||
|
||||
@@ -753,8 +1000,13 @@
|
||||
function archiveRow(message) {
|
||||
const content = message.message_type === 'voice' && message.voice_url
|
||||
? audioPlayer(publicUrl(message.voice_url), '', message.voice_mime) : esc(message.message);
|
||||
const reply = message.reply_to_id
|
||||
? `<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>
|
||||
<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() {
|
||||
|
||||
+175
-17
@@ -191,13 +191,18 @@ function removeLegacyGroupSchema(PDO $db, bool $mainDatabase): void {
|
||||
voice_file TEXT,
|
||||
voice_mime TEXT,
|
||||
voice_duration REAL,
|
||||
voice_bitrate_kbps INTEGER,
|
||||
reply_to_id INTEGER,
|
||||
reply_depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
day_key TEXT NOT NULL" .
|
||||
($mainDatabase ? ", FOREIGN KEY(user_id) REFERENCES users(id)" : '') . "
|
||||
)");
|
||||
$db->exec("INSERT INTO messages_without_groups
|
||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||
SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key
|
||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
|
||||
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
|
||||
SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
|
||||
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key
|
||||
FROM messages WHERE group_id IS NULL");
|
||||
$db->exec("DROP TABLE messages");
|
||||
$db->exec("ALTER TABLE messages_without_groups RENAME TO messages");
|
||||
@@ -268,6 +273,9 @@ function initDB(PDO $db): void {
|
||||
voice_file TEXT,
|
||||
voice_mime TEXT,
|
||||
voice_duration REAL,
|
||||
voice_bitrate_kbps INTEGER,
|
||||
reply_to_id INTEGER,
|
||||
reply_depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
day_key TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
@@ -277,6 +285,9 @@ function initDB(PDO $db): void {
|
||||
'voice_file' => 'TEXT',
|
||||
'voice_mime' => 'TEXT',
|
||||
'voice_duration' => 'REAL',
|
||||
'voice_bitrate_kbps' => 'INTEGER',
|
||||
'reply_to_id' => 'INTEGER',
|
||||
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
|
||||
]);
|
||||
removeLegacyGroupSchema($db, true);
|
||||
|
||||
@@ -339,6 +350,14 @@ function initDB(PDO $db): void {
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)");
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS auth_captcha_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
operation TEXT NOT NULL,
|
||||
answer_hash TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
$recordingColumns = tableColumns($db, 'recording_status');
|
||||
if ($recordingColumns && !isset($recordingColumns['channel_key'])) {
|
||||
$db->exec("DROP TABLE recording_status");
|
||||
@@ -368,9 +387,11 @@ function initDB(PDO $db): void {
|
||||
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_reply ON messages(reply_to_id)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_event_created ON visitor_events(created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_auth_captcha_expires ON auth_captcha_challenges(expires_at)");
|
||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL");
|
||||
|
||||
seedPlans($db);
|
||||
@@ -383,7 +404,8 @@ function seedPlans(PDO $db): void {
|
||||
'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.',
|
||||
'price' => 0, 'sort' => 0,
|
||||
'features' => [
|
||||
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
|
||||
'voice_messages' => true, 'voice_bitrate_kbps' => 64,
|
||||
'recording_status' => false, 'auto_voice_send' => false,
|
||||
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
|
||||
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
||||
],
|
||||
@@ -392,7 +414,8 @@ function seedPlans(PDO $db): void {
|
||||
'name' => 'Plus', 'description' => 'Premium voice, identity, security, and 90-day personal archives.',
|
||||
'price' => 499, 'sort' => 10,
|
||||
'features' => [
|
||||
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
|
||||
'voice_messages' => true, 'voice_bitrate_kbps' => 96,
|
||||
'recording_status' => true, 'auto_voice_send' => true,
|
||||
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 90,
|
||||
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
|
||||
],
|
||||
@@ -401,7 +424,8 @@ function seedPlans(PDO $db): void {
|
||||
'name' => 'Pro', 'description' => 'Premium voice, security, and one year of personal archives.',
|
||||
'price' => 999, 'sort' => 20,
|
||||
'features' => [
|
||||
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
|
||||
'voice_messages' => true, 'voice_bitrate_kbps' => 128,
|
||||
'recording_status' => true, 'auto_voice_send' => true,
|
||||
'auto_voice_play' => true, 'username_color' => true, 'text_archive_days' => 365,
|
||||
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
|
||||
],
|
||||
@@ -512,6 +536,128 @@ function clientIP(): string {
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
function authCaptchaEnabledFor(string $operation): bool {
|
||||
if (!in_array($operation, ['register', 'login'], true)) return false;
|
||||
$key = $operation === 'register' ? 'registration_enabled' : 'login_enabled';
|
||||
return (bool)getConfigVal('security.captcha.' . $key, false);
|
||||
}
|
||||
|
||||
function createAuthCaptchaChallenge(string $operation): array {
|
||||
if (!in_array($operation, ['register', 'login'], true)) {
|
||||
jsonResponse(['error' => 'Invalid CAPTCHA operation'], 400);
|
||||
}
|
||||
if (!authCaptchaEnabledFor($operation) || !getConfigVal('security.captcha.internal.enabled', false)) {
|
||||
return ['enabled' => false];
|
||||
}
|
||||
|
||||
$difficulty = (string)getConfigVal('security.captcha.internal.difficulty', 'easy');
|
||||
$upper = $difficulty === 'medium' ? 25 : 9;
|
||||
$left = random_int(1, $upper);
|
||||
$right = random_int(1, $upper);
|
||||
$answer = $left + $right;
|
||||
$ttl = max(60, min(1800, (int)getConfigVal('security.captcha.internal.ttl_seconds', 300)));
|
||||
$id = bin2hex(random_bytes(24));
|
||||
$hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt');
|
||||
$db = getDB();
|
||||
$db->prepare("DELETE FROM auth_captcha_challenges WHERE expires_at<=?")->execute([time()]);
|
||||
$db->prepare("INSERT INTO auth_captcha_challenges
|
||||
(id,operation,answer_hash,expires_at,created_at) VALUES (?,?,?,?,?)")
|
||||
->execute([$id, $operation, hash_hmac('sha256', (string)$answer, $hashKey), time() + $ttl, time()]);
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'token' => $id,
|
||||
'question' => "$left + $right = ?",
|
||||
'expires_in' => $ttl,
|
||||
];
|
||||
}
|
||||
|
||||
function authCaptchaFailure(string $reason): never {
|
||||
error_log('Authentication CAPTCHA rejected: ' . $reason);
|
||||
trackEvent('captcha_failed', '/api/auth.php');
|
||||
jsonResponse(['error' => 'CAPTCHA verification failed'], 403);
|
||||
}
|
||||
|
||||
function validateAuthCaptcha(string $operation): void {
|
||||
if (!authCaptchaEnabledFor($operation)) return;
|
||||
|
||||
if (getConfigVal('security.captcha.honeypot.enabled', true)) {
|
||||
$website = trim((string)($_POST['_hp_website'] ?? ''));
|
||||
$contact = filter_var($_POST['_hp_contact'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
if ($website !== '' || $contact) authCaptchaFailure('honeypot');
|
||||
}
|
||||
|
||||
if (getConfigVal('security.captcha.internal.enabled', false)) {
|
||||
$token = trim((string)($_POST['captcha_internal_token'] ?? ''));
|
||||
$answer = trim((string)($_POST['captcha_internal_answer'] ?? ''));
|
||||
if ($token === '' || $answer === '') authCaptchaFailure('internal');
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM auth_captcha_challenges
|
||||
WHERE id=? AND operation=? AND expires_at>?");
|
||||
$stmt->execute([$token, $operation, time()]);
|
||||
$challenge = $stmt->fetch();
|
||||
$maxAttempts = max(1, min(10, (int)getConfigVal('security.captcha.internal.max_attempts', 3)));
|
||||
$hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt');
|
||||
$answerHash = hash_hmac('sha256', $answer, $hashKey);
|
||||
if (!$challenge || (int)$challenge['attempts'] >= $maxAttempts
|
||||
|| !hash_equals((string)$challenge['answer_hash'], $answerHash)) {
|
||||
if ($challenge) {
|
||||
$db->prepare("UPDATE auth_captcha_challenges SET attempts=attempts+1 WHERE id=?")
|
||||
->execute([$token]);
|
||||
if ((int)$challenge['attempts'] + 1 >= $maxAttempts) {
|
||||
$db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]);
|
||||
}
|
||||
}
|
||||
authCaptchaFailure('internal');
|
||||
}
|
||||
$db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]);
|
||||
}
|
||||
|
||||
if (!getConfigVal('security.captcha.google.enabled', false)) return;
|
||||
$secret = trim((string)getConfigVal('security.captcha.google.secret_key', ''));
|
||||
$responseToken = trim((string)($_POST['captcha_google_token'] ?? ''));
|
||||
if ($secret === '' || $responseToken === '') authCaptchaFailure('google');
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new RuntimeException('Google reCAPTCHA requires the PHP cURL extension.');
|
||||
}
|
||||
|
||||
$curl = curl_init('https://www.google.com/recaptcha/api/siteverify');
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'secret' => $secret,
|
||||
'response' => $responseToken,
|
||||
'remoteip' => clientIP(),
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
||||
]);
|
||||
$raw = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if ($raw === false || $status < 200 || $status >= 300) {
|
||||
error_log('Google reCAPTCHA verification failed: ' . ($error ?: 'HTTP ' . $status));
|
||||
authCaptchaFailure('google');
|
||||
}
|
||||
$result = json_decode((string)$raw, true);
|
||||
if (!is_array($result) || empty($result['success'])) authCaptchaFailure('google');
|
||||
|
||||
$hostname = trim((string)getConfigVal('security.captcha.google.hostname', ''));
|
||||
if ($hostname !== '' && !hash_equals(strtolower($hostname), strtolower((string)($result['hostname'] ?? '')))) {
|
||||
authCaptchaFailure('google');
|
||||
}
|
||||
if (getConfigVal('security.captcha.google.version', 'v2') === 'v3') {
|
||||
$score = (float)($result['score'] ?? 0);
|
||||
$minimum = max(0.0, min(1.0, (float)getConfigVal('security.captcha.google.v3_min_score', 0.5)));
|
||||
if (($result['action'] ?? '') !== $operation || $score < $minimum) {
|
||||
authCaptchaFailure('google');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(array $data, int $code = 200): never {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
@@ -593,15 +739,19 @@ function getArchiveDB(string $year, string $month, string $day): PDO {
|
||||
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,color TEXT NOT NULL,message TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL DEFAULT 'text',voice_file TEXT,voice_mime TEXT,
|
||||
voice_duration REAL,created_at INTEGER NOT NULL,day_key TEXT NOT NULL
|
||||
voice_duration REAL,voice_bitrate_kbps INTEGER,reply_to_id INTEGER,
|
||||
reply_depth INTEGER NOT NULL DEFAULT 0,created_at INTEGER NOT NULL,day_key TEXT NOT NULL
|
||||
)");
|
||||
ensureColumns($db, 'messages', [
|
||||
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||||
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
|
||||
'voice_bitrate_kbps' => 'INTEGER', 'reply_to_id' => 'INTEGER',
|
||||
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
|
||||
]);
|
||||
removeLegacyGroupSchema($db, false);
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
|
||||
return $db;
|
||||
}
|
||||
|
||||
@@ -631,13 +781,15 @@ function archiveYesterdayIfNeeded(): void {
|
||||
$archive = getArchiveDB($year, $month, $day);
|
||||
$archive->beginTransaction();
|
||||
$insert = $archive->prepare("INSERT OR IGNORE INTO messages
|
||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
|
||||
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||
foreach ($rows as $row) {
|
||||
$insert->execute([
|
||||
$row['id'], $row['user_id'], $row['username'], $row['color'],
|
||||
$row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'],
|
||||
$row['voice_duration'], $row['created_at'], $row['day_key'],
|
||||
$row['voice_duration'], $row['voice_bitrate_kbps'], $row['reply_to_id'],
|
||||
$row['reply_depth'], $row['created_at'], $row['day_key'],
|
||||
]);
|
||||
}
|
||||
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES (?,?)");
|
||||
@@ -655,22 +807,28 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
|
||||
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
|
||||
$rows = [];
|
||||
$db = getDB();
|
||||
$sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
|
||||
$sql = "SELECT m.*,p.username AS reply_username,p.message AS reply_message,
|
||||
p.message_type AS reply_message_type
|
||||
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id
|
||||
WHERE m.user_id=? AND m.created_at>=?";
|
||||
$params = [$user['id'], $cutoff];
|
||||
if (!$includeVoice) $sql .= " AND message_type='text'";
|
||||
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; }
|
||||
$stmt = $db->prepare($sql . " ORDER BY created_at DESC LIMIT ?");
|
||||
if (!$includeVoice) $sql .= " AND m.message_type='text'";
|
||||
if ($search !== '') { $sql .= " AND m.message LIKE ?"; $params[] = '%' . $search . '%'; }
|
||||
$stmt = $db->prepare($sql . " ORDER BY m.created_at DESC LIMIT ?");
|
||||
$params[] = $limit;
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
|
||||
$archive = getArchiveDB($year, $month, $day);
|
||||
$archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
|
||||
$archiveSql = "SELECT m.*,p.username AS reply_username,p.message AS reply_message,
|
||||
p.message_type AS reply_message_type
|
||||
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id
|
||||
WHERE m.user_id=? AND m.created_at>=?";
|
||||
$archiveParams = [$user['id'], $cutoff];
|
||||
if (!$includeVoice) $archiveSql .= " AND message_type='text'";
|
||||
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
|
||||
$archiveStmt = $archive->prepare($archiveSql . " ORDER BY created_at DESC LIMIT ?");
|
||||
if (!$includeVoice) $archiveSql .= " AND m.message_type='text'";
|
||||
if ($search !== '') { $archiveSql .= " AND m.message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
|
||||
$archiveStmt = $archive->prepare($archiveSql . " ORDER BY m.created_at DESC LIMIT ?");
|
||||
$archiveParams[] = $limit;
|
||||
$archiveStmt->execute($archiveParams);
|
||||
array_push($rows, ...$archiveStmt->fetchAll());
|
||||
|
||||
+24
-1
@@ -10,6 +10,8 @@
|
||||
"poll_max_backoff_ms": 15000,
|
||||
"messages_per_page": 100,
|
||||
"max_rendered_messages": 500,
|
||||
"replies_enabled": true,
|
||||
"reply_max_depth": 4,
|
||||
"session_lock_ip": true,
|
||||
"session_lock_cookie": true,
|
||||
"allow_guest": false
|
||||
@@ -57,7 +59,28 @@
|
||||
"max_login_attempts": 10,
|
||||
"session_timeout_hours": 24,
|
||||
"bcrypt_cost": 10,
|
||||
"allowed_origins": []
|
||||
"allowed_origins": [],
|
||||
"captcha": {
|
||||
"registration_enabled": true,
|
||||
"login_enabled": true,
|
||||
"honeypot": {
|
||||
"enabled": true
|
||||
},
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"difficulty": "easy",
|
||||
"ttl_seconds": 300,
|
||||
"max_attempts": 3
|
||||
},
|
||||
"google": {
|
||||
"enabled": false,
|
||||
"version": "v2",
|
||||
"site_key": "",
|
||||
"secret_key": "",
|
||||
"v3_min_score": 0.5,
|
||||
"hostname": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscriptions": {
|
||||
"enabled": true
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@
|
||||
<title>CyberChat — Embed Example</title>
|
||||
<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 rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3">
|
||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.4">
|
||||
<style>
|
||||
html, body {
|
||||
height: auto;
|
||||
@@ -88,13 +88,13 @@
|
||||
<span class="kw"><link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">></span>
|
||||
|
||||
<span class="cm"><!-- 2. CyberChat stylesheet --></span>
|
||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260609.3"</span><span class="kw">></span>
|
||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260609.4"</span><span class="kw">></span>
|
||||
|
||||
<span class="cm"><!-- 3. Container with any dimensions --></span>
|
||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||
|
||||
<span class="cm"><!-- 4. Script + init --></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.6"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-app.js?v=20260609.7"</span><span class="kw">></script></span>
|
||||
<span class="kw"><script></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>);
|
||||
@@ -104,7 +104,7 @@
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '';
|
||||
</script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.6"></script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.7"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
CyberChat.init('#chat-here');
|
||||
|
||||
+2
-2
@@ -10,14 +10,14 @@
|
||||
<title>Chat @ TyClifford.com</title>
|
||||
<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 rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.3">
|
||||
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260609.4">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.CYBERCHAT_BASE = '';
|
||||
</script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.6"></script>
|
||||
<script src="assets/js/cyberchat-app.js?v=20260609.7"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
CyberChat.init('#app');
|
||||
|
||||
+94
-3
@@ -4,8 +4,33 @@ function handleAdminPlatformAction(string $action): ?string {
|
||||
if ($action === 'save_platform_services') {
|
||||
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? '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([
|
||||
'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.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
|
||||
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
|
||||
@@ -64,6 +89,9 @@ function handleAdminPlatformAction(string $action): ?string {
|
||||
foreach ($boolFeatures as $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))))]);
|
||||
}
|
||||
$db->commit();
|
||||
@@ -87,7 +115,8 @@ function handleAdminPlatformAction(string $action): ?string {
|
||||
->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]);
|
||||
$id = (int)$db->lastInsertId();
|
||||
$features = [
|
||||
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
|
||||
'voice_messages' => true, 'voice_bitrate_kbps' => 64,
|
||||
'recording_status' => false, 'auto_voice_send' => false,
|
||||
'auto_voice_play' => false, 'username_color' => false, 'text_archive_days' => 30,
|
||||
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
|
||||
];
|
||||
@@ -124,7 +153,7 @@ function renderAdminPlatformTab(): void {
|
||||
$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();
|
||||
$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',
|
||||
];
|
||||
?>
|
||||
@@ -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' : '' ?>>
|
||||
<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="form-row3">
|
||||
<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="mp4_h264_aac" <?= $voiceProfile === 'mp4_h264_aac' ? 'selected' : '' ?>>MP4 / H.264 + AAC</option>
|
||||
</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>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -230,6 +317,10 @@ function renderAdminPlatformTab(): void {
|
||||
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
|
||||
min="30"
|
||||
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: ?>
|
||||
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]"
|
||||
<?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label>
|
||||
|
||||
@@ -72,14 +72,16 @@ function runVoiceTranscodeCommand(array $command, int $timeoutSeconds): array {
|
||||
function transcodeVoiceRecording(
|
||||
string $sourcePath,
|
||||
string $sourceFilename,
|
||||
string $sourceMime
|
||||
string $sourceMime,
|
||||
?int $bitrateOverride = null
|
||||
): array {
|
||||
if (!getConfigVal('voice.transcoding.enabled', false)) {
|
||||
return ['filename' => $sourceFilename, 'mime' => $sourceMime, 'transcoded' => false];
|
||||
}
|
||||
|
||||
$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)));
|
||||
$fallback = (bool)getConfigVal('voice.transcoding.fallback_to_source', true);
|
||||
$token = pathinfo($sourceFilename, PATHINFO_FILENAME);
|
||||
|
||||
Reference in New Issue
Block a user