- Voice enhancements, user management, payment method

This commit is contained in:
Ty Clifford
2026-06-08 15:44:15 -04:00
parent 9e3ff61eb7
commit 063b8dc3e8
26 changed files with 2832 additions and 927 deletions
+590 -200
View File
@@ -1,221 +1,460 @@
<?php
// bootstrap.php — Core helpers, DB init, session auth
// Core configuration, persistence, authentication, entitlements, and archives.
define('ROOT_DIR', __DIR__);
define('CONFIG_FILE', ROOT_DIR . '/config.json');
if (!defined('ROOT_DIR')) define('ROOT_DIR', __DIR__);
if (!defined('CONFIG_FILE')) 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 (!(error_reporting() & $errno)) return false;
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;
jsonResponse(['error' => 'Server error', 'detail' => basename($errfile) . ':' . $errline], 500);
}
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;
jsonResponse(['error' => 'Server exception', 'detail' => $e->getMessage()], 500);
}
});
// ─── 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) {
$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');
}
// Handle preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
http_response_code(204);
exit;
}
}
// ─── Config ───────────────────────────────────────────────────────────────
function getConfig(): array {
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 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());
}
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());
}
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];
$value = getConfig();
foreach (explode('.', $path) as $key) {
if (!is_array($value) || !array_key_exists($key, $value)) return $default;
$value = $value[$key];
}
return $val;
return $value;
}
// ─── 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");
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];
}
}
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);
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 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
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,
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',
'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')),
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)
$db->exec("CREATE TABLE IF NOT EXISTS chat_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
)");
$db->exec("CREATE TABLE IF NOT EXISTS group_members (
group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at INTEGER NOT NULL,
PRIMARY KEY(group_id, user_id),
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)");
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 = [
$db->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
group_id INTEGER,
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),
FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE
)");
ensureColumns($db, 'messages', [
'group_id' => 'INTEGER',
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT',
'voice_mime' => 'TEXT',
'voice_duration' => 'REAL',
]);
$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 recording_status (
group_key TEXT NOT NULL,
user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY(group_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_group ON messages(group_id, id)");
$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,
'group_member_limit' => 2, '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 groups for four.',
'price' => 499, 'sort' => 10,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
],
],
'pro' => [
'name' => 'Pro', 'description' => 'Expanded groups and one year of personal archives.',
'price' => 999, 'sort' => 20,
'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true,
'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true,
],
],
];
foreach ($additions as $name => $definition) {
if (!isset($columns[$name])) {
$db->exec("ALTER TABLE messages ADD COLUMN $name $definition");
$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)]);
}
}
$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 voiceUploadDir(): string {
$relative = getConfigVal('voice.upload_dir', 'uploads/voice');
return ROOT_DIR . '/' . trim((string)$relative, '/');
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['id'] = (int)$row['id'];
$row['price_cents'] = (int)$row['price_cents'];
$row['active'] = (bool)$row['active'];
}
return $rows;
}
function voicePublicPath(string $filename): string {
$relative = trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/');
return $relative . '/' . rawurlencode($filename);
function planById(int $planId): ?array {
foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan;
return null;
}
function deleteVoiceFile(?string $filename): void {
if (!$filename || basename($filename) !== $filename) return;
$path = voiceUploadDir() . '/' . $filename;
if (is_file($path)) unlink($path);
function userPlan(array $user): array {
$free = null;
foreach (plans(false) as $plan) {
if ($plan['slug'] === 'free') $free = $plan;
if ((int)($user['plan_id'] ?? 0) === $plan['id']) return $plan;
}
return $free ?? ['id' => 0, 'slug' => 'free', 'name' => 'Free', 'features' => []];
}
// ─── 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;
function featureValue(array $user, string $key, mixed $default = false): mixed {
$plan = userPlan($user);
return array_key_exists($key, $plan['features']) ? $plan['features'][$key] : $default;
}
// ─── Helpers ──────────────────────────────────────────────────────────────
function dayKey(): string {
return date('Y-m-d');
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']],
'features' => $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) {
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]);
$ip = trim(explode(',', (string)$_SERVER[$key])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
}
@@ -224,77 +463,228 @@ function clientIP(): string {
function jsonResponse(array $data, int $code = 200): never {
http_response_code($code);
header('Content-Type: application/json');
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($data);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
exit;
}
// ─── Auth guard ───────────────────────────────────────────────────────────
function authRequired(): array {
$db = getDB();
function authUser(bool $required = true): ?array {
$sid = $_COOKIE['cyberchat_sid'] ?? '';
if (!$sid) {
jsonResponse(['error' => 'Not authenticated'], 401);
if ($sid === '') {
if ($required) jsonResponse(['error' => 'Not authenticated'], 401);
return null;
}
$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 > ?
");
$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]);
$session = $stmt->fetch();
if (!$session) {
jsonResponse(['error' => 'Session expired'], 401);
$user = $stmt->fetch();
if (!$user) {
if ($required) jsonResponse(['error' => 'Session expired'], 401);
return null;
}
if (getConfigVal('chat.session_lock_ip', true) && $session['ip'] !== clientIP()) {
jsonResponse(['error' => 'Session IP mismatch — logged in elsewhere'], 401);
if (getConfigVal('chat.session_lock_ip', true) && $user['ip'] !== clientIP()) {
if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401);
return null;
}
$db->prepare("UPDATE sessions SET last_active = ? WHERE id = ?")->execute([time(), $sid]);
return $session;
getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]);
return $user;
}
// ─── Daily archive ────────────────────────────────────────────────────────
function archiveYesterdayIfNeeded(): void {
$yesterday = date('Y-m-d', strtotime('-1 day'));
$flagFile = ROOT_DIR . '/db/.archived_' . $yesterday;
if (file_exists($flagFile)) return;
function authRequired(): array {
$user = authUser(true);
$user['uid'] = $user['id'];
return $user;
}
$db = getDB();
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key = ? ORDER BY created_at ASC");
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 normalizeGroupId(mixed $value): ?int {
$id = (int)$value;
return $id > 0 ? $id : null;
}
function groupKey(?int $groupId): string {
return $groupId ? 'group:' . $groupId : 'lobby';
}
function requireGroupAccess(array $user, ?int $groupId): void {
if ($groupId === null) return;
$stmt = getDB()->prepare("SELECT 1 FROM group_members WHERE group_id=? AND user_id=?");
$stmt->execute([$groupId, $user['id']]);
if (!$stmt->fetchColumn()) jsonResponse(['error' => 'You are not a member of this group'], 403);
}
function userGroups(int $userId): array {
$stmt = getDB()->prepare("SELECT g.id,g.name,g.owner_id,g.created_at,gm.role,
(SELECT COUNT(*) FROM group_members x WHERE x.group_id=g.id) AS member_count
FROM chat_groups g JOIN group_members gm ON gm.group_id=g.id
WHERE gm.user_id=? ORDER BY g.updated_at DESC,g.id DESC");
$stmt->execute([$userId]);
$groups = $stmt->fetchAll();
$members = getDB()->prepare("SELECT u.id,u.username,u.color,gm.role FROM group_members gm
JOIN users u ON u.id=gm.user_id WHERE gm.group_id=? ORDER BY gm.role='owner' DESC,u.username");
foreach ($groups as &$group) {
$group['id'] = (int)$group['id'];
$group['owner_id'] = (int)$group['owner_id'];
$group['member_count'] = (int)$group['member_count'];
$members->execute([$group['id']]);
$group['members'] = $members->fetchAll();
}
return $groups;
}
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, group_id INTEGER,
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', [
'group_id' => 'INTEGER', 'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
]);
$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 (!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']
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,group_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['group_id'] ?? null, $row['username'], $row['color'],
$row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'],
$row['voice_duration'], $row['created_at'], $row['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();
$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 = [];
$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 = getDB()->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;
}
$db->prepare("DELETE FROM messages WHERE day_key = ?")->execute([$yesterday]);
file_put_contents($flagFile, date('c'));
}