- 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:
+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());
|
||||
|
||||
Reference in New Issue
Block a user