v2.0.0
This commit is contained in:
+300
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
// bootstrap.php — Core helpers, DB init, session auth
|
||||
|
||||
define('ROOT_DIR', __DIR__);
|
||||
define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
||||
|
||||
// ─── Error handling: always return JSON, never raw PHP errors ────────────
|
||||
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
|
||||
// Only intercept fatal-ish errors during API requests
|
||||
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => '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'));
|
||||
}
|
||||
Reference in New Issue
Block a user