- 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
+175 -17
View File
@@ -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());