d0e4a870be
Admin tier assignment without payment via Users > Edit > Tier Override. Fixed live group membership refresh, polling, unread indicators, and incoming voice clip population. Added asset cache busting so clients receive the fixes immediately.
702 lines
29 KiB
PHP
702 lines
29 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());
|
|
}
|
|
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 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 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
|
|
)");
|
|
|
|
$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,
|
|
'auto_voice_play' => 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,
|
|
'auto_voice_play' => 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,
|
|
'auto_voice_play' => true,
|
|
'group_member_limit' => 8, '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)]);
|
|
}
|
|
}
|
|
$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['id'] = (int)$row['id'];
|
|
$row['price_cents'] = (int)$row['price_cents'];
|
|
$row['active'] = (bool)$row['active'];
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
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' => $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 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,
|
|
(SELECT MAX(m.id) FROM messages m WHERE m.group_id=g.id) AS latest_message_id,
|
|
(SELECT MAX(m.created_at) FROM messages m WHERE m.group_id=g.id) AS latest_message_at
|
|
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'];
|
|
$group['latest_message_id'] = (int)($group['latest_message_id'] ?? 0);
|
|
$group['latest_message_at'] = (int)($group['latest_message_at'] ?? 0);
|
|
$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 ($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'],
|
|
]);
|
|
}
|
|
$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;
|
|
}
|
|
}
|