- Implemented across bootstrap.php, chat frontend, and dashboard:

Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps.
Configurable text/voice reply threads and depth.
Registration/login-only honeypot and internal CAPTCHA.
Optional Google reCAPTCHA v2/v3 with server verification.
Archive/export support for replies and bitrate.
Dashboard controls for all settings.
Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and 
browser console. Native PHP lint was unavailable because PHP is not 
installed.
Google implementation follows Siteverify and reCAPTCHA v3.
This commit is contained in:
Ty Clifford
2026-06-09 10:59:58 -04:00
parent 88805057cc
commit eb57cde99f
14 changed files with 844 additions and 110 deletions
+22 -5
View File
@@ -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 (1014)</td></tr>
<tr><td class="mono">ui.app_title</td><td class="dim">string</td><td>Header title text</td></tr>