'Server error: ' . $errstr . ' in ' . basename($errfile) . ':' . $errline]); exit; } return false; }); set_exception_handler(function(Throwable $e): void { if (defined('CYBERCHAT_API') && CYBERCHAT_API) { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['error' => 'Server exception: ' . $e->getMessage()]); exit; } }); // ─── CORS headers (needed if embedding cross-origin) ───────────────────── function sendCorsHeaders(): void { $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; // Allow same-origin and any origin configured in config // For strictest security, lock this to your domain if ($origin) { header('Access-Control-Allow-Origin: ' . $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'); } // Handle preflight if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } } // ─── Config ─────────────────────────────────────────────────────────────── function getConfig(): array { static $config = null; if ($config === null) { if (!file_exists(CONFIG_FILE)) { throw new RuntimeException('config.json not found at: ' . CONFIG_FILE); } $raw = file_get_contents(CONFIG_FILE); $config = json_decode($raw, true); if ($config === null) { throw new RuntimeException('config.json is not valid JSON: ' . json_last_error_msg()); } } return $config; } function getConfigVal(string $path, mixed $default = null): mixed { $config = getConfig(); $keys = explode('.', $path); $val = $config; foreach ($keys as $key) { if (!is_array($val) || !array_key_exists($key, $val)) return $default; $val = $val[$key]; } return $val; } // ─── Database ───────────────────────────────────────────────────────────── function getDB(): PDO { static $db = null; if ($db === null) { $dbRelPath = getConfigVal('database.main_db', 'db/chat.sqlite'); $dbPath = ROOT_DIR . '/' . ltrim($dbRelPath, '/'); $dir = dirname($dbPath); if (!is_dir($dir)) { if (!mkdir($dir, 0755, true)) { throw new RuntimeException("Cannot create db directory: $dir — check permissions"); } } if (!is_writable($dir)) { throw new RuntimeException("db directory is not writable: $dir — run: chmod 755 $dir"); } $db = new PDO('sqlite:' . $dbPath); $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); } return $db; } 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, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), last_seen INTEGER )"); $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) )"); ensureMessageColumns($db); $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(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)"); } function ensureMessageColumns(PDO $db): void { $columns = []; foreach ($db->query("PRAGMA table_info(messages)") as $column) { $columns[$column['name']] = true; } $additions = [ 'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', ]; foreach ($additions as $name => $definition) { if (!isset($columns[$name])) { $db->exec("ALTER TABLE messages ADD COLUMN $name $definition"); } } } function voiceUploadDir(): string { $relative = getConfigVal('voice.upload_dir', 'uploads/voice'); return ROOT_DIR . '/' . trim((string)$relative, '/'); } function voicePublicPath(string $filename): string { $relative = trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/'); return $relative . '/' . rawurlencode($filename); } function deleteVoiceFile(?string $filename): void { if (!$filename || basename($filename) !== $filename) return; $path = voiceUploadDir() . '/' . $filename; if (is_file($path)) unlink($path); } // ─── Archive DB ─────────────────────────────────────────────────────────── function getArchiveDB(string $year, string $month, string $day): PDO { $tpl = getConfigVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite'); $relPath = str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl); $fullPath = ROOT_DIR . '/' . ltrim($relPath, '/'); $dir = dirname($fullPath); if (!is_dir($dir)) mkdir($dir, 0755, true); $adb = new PDO('sqlite:' . $fullPath); $adb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $adb->exec('PRAGMA journal_mode=WAL'); $adb->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, day_key TEXT NOT NULL )"); ensureMessageColumns($adb); $adb->exec("CREATE TABLE IF NOT EXISTS archive_meta ( key TEXT PRIMARY KEY, value TEXT )"); return $adb; } // ─── Helpers ────────────────────────────────────────────────────────────── function dayKey(): string { return date('Y-m-d'); } 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(',', $_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'); header('Cache-Control: no-store'); echo json_encode($data); exit; } // ─── Auth guard ─────────────────────────────────────────────────────────── function authRequired(): array { $db = getDB(); $sid = $_COOKIE['cyberchat_sid'] ?? ''; if (!$sid) { jsonResponse(['error' => 'Not authenticated'], 401); } $timeout = (int)getConfigVal('security.session_timeout_hours', 24) * 3600; $cutoff = time() - $timeout; $stmt = $db->prepare(" SELECT s.id, s.ip, s.last_active, u.id AS uid, u.username, u.color FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.last_active > ? "); $stmt->execute([$sid, $cutoff]); $session = $stmt->fetch(); if (!$session) { jsonResponse(['error' => 'Session expired'], 401); } if (getConfigVal('chat.session_lock_ip', true) && $session['ip'] !== clientIP()) { jsonResponse(['error' => 'Session IP mismatch — logged in elsewhere'], 401); } $db->prepare("UPDATE sessions SET last_active = ? WHERE id = ?")->execute([time(), $sid]); return $session; } // ─── Daily archive ──────────────────────────────────────────────────────── function archiveYesterdayIfNeeded(): void { $yesterday = date('Y-m-d', strtotime('-1 day')); $flagFile = ROOT_DIR . '/db/.archived_' . $yesterday; if (file_exists($flagFile)) return; $db = getDB(); $stmt = $db->prepare("SELECT * FROM messages WHERE day_key = ? ORDER BY created_at ASC"); $stmt->execute([$yesterday]); $rows = $stmt->fetchAll(); if (!empty($rows)) { [$y, $m, $d] = explode('-', $yesterday); $adb = getArchiveDB($y, $m, $d); $adb->beginTransaction(); $ins = $adb->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 $r) { $ins->execute([ $r['id'],$r['user_id'],$r['username'],$r['color'],$r['message'], $r['message_type'] ?? 'text',$r['voice_file'] ?? null,$r['voice_mime'] ?? null, $r['voice_duration'] ?? null,$r['created_at'],$r['day_key'] ]); } $adb->exec("INSERT OR REPLACE INTO archive_meta(key,value) VALUES('archived_at','" . time() . "')"); $adb->exec("INSERT OR REPLACE INTO archive_meta(key,value) VALUES('message_count','" . count($rows) . "')"); $adb->commit(); } $db->prepare("DELETE FROM messages WHERE day_key = ?")->execute([$yesterday]); file_put_contents($flagFile, date('c')); }