eb57cde99f
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.
867 lines
37 KiB
PHP
867 lines
37 KiB
PHP
<?php
|
|
// Core configuration, persistence, authentication, entitlements, and archives.
|
|
|
|
if (!defined('ROOT_DIR')) define('ROOT_DIR', __DIR__);
|
|
if (!defined('CONFIG_FILE')) define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
|
|
|
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
|
|
if (!(error_reporting() & $errno)) return false;
|
|
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
|
jsonResponse(['error' => 'Server error', 'detail' => basename($errfile) . ':' . $errline], 500);
|
|
}
|
|
return false;
|
|
});
|
|
|
|
set_exception_handler(function(Throwable $e): void {
|
|
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
|
jsonResponse(['error' => 'Server exception', 'detail' => $e->getMessage()], 500);
|
|
}
|
|
});
|
|
|
|
function sendCorsHeaders(): void {
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
$allowed = (array)getConfigVal('security.allowed_origins', []);
|
|
$hostOrigin = requestOrigin();
|
|
if ($origin && ($origin === $hostOrigin || in_array($origin, $allowed, true))) {
|
|
header('Access-Control-Allow-Origin: ' . $origin);
|
|
header('Vary: Origin');
|
|
header('Access-Control-Allow-Credentials: true');
|
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
|
|
}
|
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
function requestOrigin(): string {
|
|
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
|
$scheme = $https ? 'https' : 'http';
|
|
return $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
|
}
|
|
|
|
function appBaseUrl(): string {
|
|
$configured = trim((string)getConfigVal('app.base_url', ''));
|
|
if ($configured !== '') return rtrim($configured, '/');
|
|
$script = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/'));
|
|
if (str_ends_with($script, '/api')) $script = substr($script, 0, -4);
|
|
return requestOrigin() . rtrim($script, '/');
|
|
}
|
|
|
|
function getConfig(bool $reload = false): array {
|
|
static $config = null;
|
|
if ($reload) $config = null;
|
|
if ($config === null) {
|
|
if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found');
|
|
$config = json_decode((string)file_get_contents(CONFIG_FILE), true);
|
|
if (!is_array($config)) throw new RuntimeException('config.json is invalid: ' . json_last_error_msg());
|
|
if (array_key_exists('groups', $config)) {
|
|
unset($config['groups']);
|
|
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
if ($json !== false && is_writable(CONFIG_FILE)) {
|
|
file_put_contents(CONFIG_FILE, $json . PHP_EOL, LOCK_EX);
|
|
}
|
|
}
|
|
}
|
|
return $config;
|
|
}
|
|
|
|
function getConfigVal(string $path, mixed $default = null): mixed {
|
|
$value = getConfig();
|
|
foreach (explode('.', $path) as $key) {
|
|
if (!is_array($value) || !array_key_exists($key, $value)) return $default;
|
|
$value = $value[$key];
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
function setConfigValues(array $changes): void {
|
|
$config = getConfig();
|
|
foreach ($changes as $path => $value) {
|
|
$keys = explode('.', $path);
|
|
$cursor =& $config;
|
|
foreach ($keys as $index => $key) {
|
|
if ($index === count($keys) - 1) {
|
|
$cursor[$key] = $value;
|
|
} else {
|
|
if (!isset($cursor[$key]) || !is_array($cursor[$key])) $cursor[$key] = [];
|
|
$cursor =& $cursor[$key];
|
|
}
|
|
}
|
|
unset($cursor);
|
|
}
|
|
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
if ($json === false || file_put_contents(CONFIG_FILE, $json . PHP_EOL, LOCK_EX) === false) {
|
|
throw new RuntimeException('Could not save config.json');
|
|
}
|
|
getConfig(true);
|
|
}
|
|
|
|
function storagePath(string $configured): string {
|
|
$configured = trim($configured);
|
|
if ($configured === '') throw new RuntimeException('Storage path is empty');
|
|
if (str_starts_with($configured, '/') || preg_match('/^[A-Za-z]:[\\\\\/]/', $configured)) {
|
|
return $configured;
|
|
}
|
|
return ROOT_DIR . '/' . ltrim($configured, '/\\');
|
|
}
|
|
|
|
function ensureWritableDirectory(string $dir, string $label): void {
|
|
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
|
throw new RuntimeException("Cannot create $label directory: $dir");
|
|
}
|
|
if (!is_writable($dir)) {
|
|
throw new RuntimeException(
|
|
"$label directory is not writable by PHP: $dir. "
|
|
. "Grant the web-server user write access to this directory."
|
|
);
|
|
}
|
|
$probe = @tempnam($dir, '.cyberchat-write-');
|
|
if ($probe === false) {
|
|
throw new RuntimeException(
|
|
"$label directory cannot create files: $dir. "
|
|
. "Grant the web-server user ownership or write access."
|
|
);
|
|
}
|
|
@unlink($probe);
|
|
}
|
|
|
|
function getDB(): PDO {
|
|
static $db = null;
|
|
if ($db !== null) return $db;
|
|
|
|
$path = storagePath((string)getConfigVal('database.main_db', 'db/chat.sqlite'));
|
|
$dir = dirname($path);
|
|
ensureWritableDirectory($dir, 'Database');
|
|
if (is_file($path) && !is_writable($path)) {
|
|
throw new RuntimeException(
|
|
"SQLite database is not writable by PHP: $path. "
|
|
. "Grant the web-server user write access to the file and its directory."
|
|
);
|
|
}
|
|
try {
|
|
$db = new PDO('sqlite:' . $path);
|
|
} catch (PDOException $e) {
|
|
throw new RuntimeException(
|
|
"SQLite cannot open $path. Confirm that PHP can write to $dir.",
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
|
$db->exec('PRAGMA journal_mode=WAL');
|
|
$db->exec('PRAGMA synchronous=NORMAL');
|
|
$db->exec('PRAGMA foreign_keys=ON');
|
|
initDB($db);
|
|
maybeMirrorToMySQL($db);
|
|
return $db;
|
|
}
|
|
|
|
function tableColumns(PDO $db, string $table): array {
|
|
$columns = [];
|
|
foreach ($db->query("PRAGMA table_info($table)") as $column) $columns[$column['name']] = true;
|
|
return $columns;
|
|
}
|
|
|
|
function ensureColumns(PDO $db, string $table, array $additions): void {
|
|
$columns = tableColumns($db, $table);
|
|
foreach ($additions as $name => $definition) {
|
|
if (!isset($columns[$name])) $db->exec("ALTER TABLE $table ADD COLUMN $name $definition");
|
|
}
|
|
}
|
|
|
|
function removeLegacyGroupSchema(PDO $db, bool $mainDatabase): void {
|
|
$messageColumns = tableColumns($db, 'messages');
|
|
if (isset($messageColumns['group_id'])) {
|
|
$voiceFiles = $db->query("SELECT DISTINCT voice_file FROM messages
|
|
WHERE group_id IS NOT NULL AND voice_file IS NOT NULL AND voice_file != ''")->fetchAll(PDO::FETCH_COLUMN);
|
|
$db->exec('PRAGMA foreign_keys=OFF');
|
|
try {
|
|
$db->beginTransaction();
|
|
$db->exec("DROP TABLE IF EXISTS messages_without_groups");
|
|
$db->exec("CREATE TABLE messages_without_groups (
|
|
id INTEGER PRIMARY KEY" . ($mainDatabase ? ' AUTOINCREMENT' : '') . ",
|
|
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,
|
|
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,
|
|
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");
|
|
$db->exec("DROP TABLE IF EXISTS group_members");
|
|
$db->exec("DROP TABLE IF EXISTS chat_groups");
|
|
$db->commit();
|
|
foreach ($voiceFiles as $voiceFile) deleteVoiceFile((string)$voiceFile);
|
|
} catch (Throwable $e) {
|
|
if ($db->inTransaction()) $db->rollBack();
|
|
throw $e;
|
|
} finally {
|
|
$db->exec('PRAGMA foreign_keys=ON');
|
|
}
|
|
} else {
|
|
$db->exec("DROP TABLE IF EXISTS group_members");
|
|
$db->exec("DROP TABLE IF EXISTS chat_groups");
|
|
}
|
|
}
|
|
|
|
function initDB(PDO $db): void {
|
|
$db->exec("CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
|
password_hash TEXT NOT NULL,
|
|
color TEXT NOT NULL,
|
|
email TEXT,
|
|
email_verified_at INTEGER,
|
|
plan_id INTEGER,
|
|
manual_plan_id INTEGER,
|
|
stripe_customer_id TEXT,
|
|
stripe_subscription_id TEXT,
|
|
subscription_status TEXT NOT NULL DEFAULT 'free',
|
|
subscription_period_end INTEGER,
|
|
cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
|
|
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
last_seen INTEGER
|
|
)");
|
|
ensureColumns($db, 'users', [
|
|
'email' => 'TEXT',
|
|
'email_verified_at' => 'INTEGER',
|
|
'plan_id' => 'INTEGER',
|
|
'manual_plan_id' => 'INTEGER',
|
|
'stripe_customer_id' => 'TEXT',
|
|
'stripe_subscription_id' => 'TEXT',
|
|
'subscription_status' => "TEXT NOT NULL DEFAULT 'free'",
|
|
'subscription_period_end' => 'INTEGER',
|
|
'cancel_at_period_end' => 'INTEGER NOT NULL DEFAULT 0',
|
|
'two_factor_enabled' => 'INTEGER NOT NULL DEFAULT 0',
|
|
]);
|
|
|
|
$db->exec("CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
ip TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
last_active INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)");
|
|
|
|
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
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,
|
|
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)
|
|
)");
|
|
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, true);
|
|
|
|
$db->exec("CREATE TABLE IF NOT EXISTS plans (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
price_cents INTEGER NOT NULL DEFAULT 0,
|
|
currency TEXT NOT NULL DEFAULT 'usd',
|
|
billing_interval TEXT NOT NULL DEFAULT 'month',
|
|
stripe_price_id TEXT,
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
)");
|
|
$db->exec("CREATE TABLE IF NOT EXISTS plan_features (
|
|
plan_id INTEGER NOT NULL,
|
|
feature_key TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
PRIMARY KEY(plan_id, feature_key),
|
|
FOREIGN KEY(plan_id) REFERENCES plans(id) ON DELETE CASCADE
|
|
)");
|
|
$db->exec("CREATE TABLE IF NOT EXISTS subscriptions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER UNIQUE NOT NULL,
|
|
plan_id INTEGER,
|
|
stripe_customer_id TEXT,
|
|
stripe_subscription_id TEXT UNIQUE,
|
|
status TEXT NOT NULL DEFAULT 'free',
|
|
current_period_end INTEGER,
|
|
cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER NOT NULL,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY(plan_id) REFERENCES plans(id)
|
|
)");
|
|
$db->exec("CREATE TABLE IF NOT EXISTS email_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
email TEXT NOT NULL,
|
|
purpose TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL,
|
|
code_hash TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
used_at INTEGER,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)");
|
|
ensureColumns($db, 'email_tokens', [
|
|
'attempts' => 'INTEGER NOT NULL DEFAULT 0',
|
|
]);
|
|
$db->exec("CREATE TABLE IF NOT EXISTS login_challenges (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
code_hash TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
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");
|
|
}
|
|
$db->exec("CREATE TABLE IF NOT EXISTS recording_status (
|
|
channel_key TEXT NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
PRIMARY KEY(channel_key, user_id),
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)");
|
|
$db->exec("CREATE TABLE IF NOT EXISTS visitor_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER,
|
|
event_type TEXT NOT NULL,
|
|
path TEXT NOT NULL DEFAULT '',
|
|
ip_hash TEXT NOT NULL,
|
|
user_agent TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
)");
|
|
$db->exec("CREATE TABLE IF NOT EXISTS stripe_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
event_type TEXT NOT NULL,
|
|
received_at INTEGER NOT NULL
|
|
)");
|
|
|
|
$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);
|
|
}
|
|
|
|
function seedPlans(PDO $db): void {
|
|
$now = time();
|
|
$defaults = [
|
|
'free' => [
|
|
'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.',
|
|
'price' => 0, 'sort' => 0,
|
|
'features' => [
|
|
'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,
|
|
],
|
|
],
|
|
'plus' => [
|
|
'name' => 'Plus', 'description' => 'Premium voice, identity, security, and 90-day personal archives.',
|
|
'price' => 499, 'sort' => 10,
|
|
'features' => [
|
|
'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,
|
|
],
|
|
],
|
|
'pro' => [
|
|
'name' => 'Pro', 'description' => 'Premium voice, security, and one year of personal archives.',
|
|
'price' => 999, 'sort' => 20,
|
|
'features' => [
|
|
'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,
|
|
],
|
|
],
|
|
];
|
|
|
|
$insertPlan = $db->prepare("INSERT OR IGNORE INTO plans
|
|
(slug,name,description,price_cents,currency,billing_interval,active,sort_order,created_at,updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)");
|
|
$insertFeature = $db->prepare("INSERT OR IGNORE INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
|
|
foreach ($defaults as $slug => $plan) {
|
|
$insertPlan->execute([$slug, $plan['name'], $plan['description'], $plan['price'], 'usd', 'month', 1, $plan['sort'], $now, $now]);
|
|
$stmt = $db->prepare("SELECT id FROM plans WHERE slug = ?");
|
|
$stmt->execute([$slug]);
|
|
$planId = (int)$stmt->fetchColumn();
|
|
foreach ($plan['features'] as $key => $value) {
|
|
$insertFeature->execute([$planId, $key, json_encode($value)]);
|
|
}
|
|
}
|
|
$db->exec("DELETE FROM plan_features WHERE feature_key LIKE 'group%'");
|
|
$db->exec("UPDATE plans SET description='Configurable premium chat features.'
|
|
WHERE lower(description) LIKE '%group%'");
|
|
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn();
|
|
$db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
|
|
}
|
|
|
|
function plans(bool $activeOnly = false): array {
|
|
$db = getDB();
|
|
$sql = "SELECT * FROM plans" . ($activeOnly ? " WHERE active = 1" : "") . " ORDER BY sort_order, price_cents, id";
|
|
$rows = $db->query($sql)->fetchAll();
|
|
$featureStmt = $db->prepare("SELECT feature_key,value_json FROM plan_features WHERE plan_id = ?");
|
|
foreach ($rows as &$row) {
|
|
$featureStmt->execute([$row['id']]);
|
|
$row['features'] = [];
|
|
foreach ($featureStmt->fetchAll() as $feature) {
|
|
$row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true);
|
|
}
|
|
$row['features'] = visiblePlanFeatures($row['features']);
|
|
$row['id'] = (int)$row['id'];
|
|
$row['price_cents'] = (int)$row['price_cents'];
|
|
$row['active'] = (bool)$row['active'];
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
function visiblePlanFeatures(array $features): array {
|
|
return array_filter(
|
|
$features,
|
|
fn(string $key): bool => !str_starts_with(strtolower($key), 'group'),
|
|
ARRAY_FILTER_USE_KEY
|
|
);
|
|
}
|
|
|
|
function planById(int $planId): ?array {
|
|
foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan;
|
|
return null;
|
|
}
|
|
|
|
function userPlan(array $user): array {
|
|
$free = null;
|
|
$effectivePlanId = (int)($user['manual_plan_id'] ?? 0) ?: (int)($user['plan_id'] ?? 0);
|
|
foreach (plans(false) as $plan) {
|
|
if ($plan['slug'] === 'free') $free = $plan;
|
|
if ($effectivePlanId === $plan['id']) return $plan;
|
|
}
|
|
return $free ?? ['id' => 0, 'slug' => 'free', 'name' => 'Free', 'features' => []];
|
|
}
|
|
|
|
function featureValue(array $user, string $key, mixed $default = false): mixed {
|
|
$plan = userPlan($user);
|
|
return array_key_exists($key, $plan['features']) ? $plan['features'][$key] : $default;
|
|
}
|
|
|
|
function userPublicPayload(array $user): array {
|
|
$plan = userPlan($user);
|
|
return [
|
|
'id' => (int)$user['id'],
|
|
'username' => $user['username'],
|
|
'color' => $user['color'],
|
|
'email' => $user['email'] ?? null,
|
|
'email_verified' => !empty($user['email_verified_at']),
|
|
'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']],
|
|
'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing',
|
|
'features' => visiblePlanFeatures($plan['features']),
|
|
'subscription' => [
|
|
'status' => $user['subscription_status'] ?? 'free',
|
|
'period_end' => isset($user['subscription_period_end']) ? (int)$user['subscription_period_end'] : null,
|
|
'cancel_at_period_end' => !empty($user['cancel_at_period_end']),
|
|
'manageable' => !empty($user['stripe_customer_id']),
|
|
],
|
|
'two_factor_enabled' => !empty($user['two_factor_enabled']),
|
|
];
|
|
}
|
|
|
|
function userById(int $id): ?array {
|
|
$stmt = getDB()->prepare("SELECT * FROM users WHERE id = ?");
|
|
$stmt->execute([$id]);
|
|
return $stmt->fetch() ?: null;
|
|
}
|
|
|
|
function clientIP(): string {
|
|
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $key) {
|
|
if (!empty($_SERVER[$key])) {
|
|
$ip = trim(explode(',', (string)$_SERVER[$key])[0]);
|
|
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
|
|
}
|
|
}
|
|
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');
|
|
header('Cache-Control: no-store');
|
|
echo json_encode($data, JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
function authUser(bool $required = true): ?array {
|
|
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
|
if ($sid === '') {
|
|
if ($required) jsonResponse(['error' => 'Not authenticated'], 401);
|
|
return null;
|
|
}
|
|
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
|
|
$stmt = getDB()->prepare("SELECT s.id AS session_id,s.ip,s.last_active,u.*
|
|
FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id=? AND s.last_active>?");
|
|
$stmt->execute([$sid, $cutoff]);
|
|
$user = $stmt->fetch();
|
|
if (!$user) {
|
|
if ($required) jsonResponse(['error' => 'Session expired'], 401);
|
|
return null;
|
|
}
|
|
if (getConfigVal('chat.session_lock_ip', true) && $user['ip'] !== clientIP()) {
|
|
if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401);
|
|
return null;
|
|
}
|
|
getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]);
|
|
return $user;
|
|
}
|
|
|
|
function authRequired(): array {
|
|
$user = authUser(true);
|
|
$user['uid'] = $user['id'];
|
|
return $user;
|
|
}
|
|
|
|
function createUserSession(PDO $db, int $userId): void {
|
|
$sid = bin2hex(random_bytes(32));
|
|
$db->prepare("DELETE FROM sessions WHERE user_id=?")->execute([$userId]);
|
|
$db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)")
|
|
->execute([$sid, $userId, clientIP(), time(), time()]);
|
|
$db->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([time(), $userId]);
|
|
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
|
setcookie('cyberchat_sid', $sid, [
|
|
'expires' => time() + ((int)getConfigVal('security.session_timeout_hours', 24) * 3600),
|
|
'path' => '/', 'httponly' => true, 'samesite' => 'Strict', 'secure' => $secure,
|
|
]);
|
|
}
|
|
|
|
function dayKey(): string {
|
|
return date('Y-m-d');
|
|
}
|
|
|
|
function voiceUploadDir(): string {
|
|
return storagePath((string)getConfigVal('voice.upload_dir', 'uploads/voice'));
|
|
}
|
|
|
|
function voicePublicPath(string $filename): string {
|
|
return trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($filename);
|
|
}
|
|
|
|
function deleteVoiceFile(?string $filename): void {
|
|
if (!$filename || basename($filename) !== $filename) return;
|
|
$path = voiceUploadDir() . '/' . $filename;
|
|
if (is_file($path)) unlink($path);
|
|
}
|
|
|
|
function getArchiveDB(string $year, string $month, string $day): PDO {
|
|
$template = (string)getConfigVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
|
$relative = str_replace(['{year}', '{month}', '{day}'], [$year, $month, $day], $template);
|
|
$path = storagePath($relative);
|
|
ensureWritableDirectory(dirname($path), 'Archive');
|
|
$db = new PDO('sqlite:' . $path);
|
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
|
$db->exec('PRAGMA journal_mode=WAL');
|
|
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
|
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,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;
|
|
}
|
|
|
|
function archiveFilesSince(int $cutoff): array {
|
|
$root = storagePath((string)getConfigVal('archive.archive_dir', 'archive'));
|
|
$files = [];
|
|
if (!is_dir($root)) return $files;
|
|
foreach (glob($root . '/*/*/*.sqlite') ?: [] as $file) {
|
|
if (!preg_match('~/(\d{4})/(\d{2})/(\d{2})\.sqlite$~', $file, $m)) continue;
|
|
$dayTs = strtotime($m[1] . '-' . $m[2] . '-' . $m[3] . ' 23:59:59');
|
|
if ($dayTs !== false && $dayTs >= $cutoff) $files[] = [$file, $m[1], $m[2], $m[3]];
|
|
}
|
|
return $files;
|
|
}
|
|
|
|
function archiveYesterdayIfNeeded(): void {
|
|
if (!getConfigVal('archive.enabled', true)) return;
|
|
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
|
$flag = ROOT_DIR . '/db/.archived_' . $yesterday;
|
|
if (file_exists($flag)) return;
|
|
$db = getDB();
|
|
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id");
|
|
$stmt->execute([$yesterday]);
|
|
$rows = $stmt->fetchAll();
|
|
if ($rows) {
|
|
[$year, $month, $day] = explode('-', $yesterday);
|
|
$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,
|
|
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['voice_bitrate_kbps'], $row['reply_to_id'],
|
|
$row['reply_depth'], $row['created_at'], $row['day_key'],
|
|
]);
|
|
}
|
|
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES (?,?)");
|
|
$meta->execute(['archived_at', (string)time()]);
|
|
$meta->execute(['message_count', (string)count($rows)]);
|
|
$archive->commit();
|
|
}
|
|
$db->prepare("DELETE FROM messages WHERE day_key=?")->execute([$yesterday]);
|
|
file_put_contents($flag, date('c'), LOCK_EX);
|
|
}
|
|
|
|
function personalArchiveRows(array $user, string $search = '', int $limit = 500): array {
|
|
$days = max(30, (int)featureValue($user, 'text_archive_days', 30));
|
|
$cutoff = time() - ($days * 86400);
|
|
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
|
|
$rows = [];
|
|
$db = getDB();
|
|
$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 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 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 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());
|
|
}
|
|
usort($rows, fn(array $a, array $b): int => ((int)$b['created_at'] <=> (int)$a['created_at']));
|
|
return array_slice($rows, 0, $limit);
|
|
}
|
|
|
|
function trackEvent(string $type, string $path = '', ?int $userId = null): void {
|
|
if (!getConfigVal('stats.enabled', true)) return;
|
|
$salt = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt');
|
|
$ipHash = hash('sha256', $salt . '|' . clientIP());
|
|
$agent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
|
|
getDB()->prepare("INSERT INTO visitor_events (user_id,event_type,path,ip_hash,user_agent,created_at)
|
|
VALUES (?,?,?,?,?,?)")->execute([$userId, $type, substr($path, 0, 255), $ipHash, $agent, time()]);
|
|
}
|
|
|
|
function maybeMirrorToMySQL(PDO $sqlite): void {
|
|
if (!getConfigVal('database.mysql.enabled', false) || !getConfigVal('database.mysql.auto_import', false)) return;
|
|
static $running = false;
|
|
if ($running) return;
|
|
$flag = ROOT_DIR . '/db/.mysql_mirror';
|
|
$interval = max(60, (int)getConfigVal('database.mysql.sync_interval_seconds', 900));
|
|
if (is_file($flag) && filemtime($flag) > time() - $interval) return;
|
|
$running = true;
|
|
try {
|
|
require_once ROOT_DIR . '/lib/mysql_mirror.php';
|
|
mirrorAllSQLiteDatabases();
|
|
file_put_contents($flag, (string)time(), LOCK_EX);
|
|
} catch (Throwable $e) {
|
|
error_log('CyberChat MySQL mirror failed: ' . $e->getMessage());
|
|
} finally {
|
|
$running = false;
|
|
}
|
|
}
|