'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, 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 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, 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', ]); 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 )"); $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_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 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, '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, '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, '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 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,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', ]); 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)"); 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,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'], ]); } $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 * FROM messages WHERE user_id=? AND 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 ?"); $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>=?"; $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 ?"); $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; } }