36c488ca1e
Rebuilt embed-example.html with inline, full-screen, and popup examples. Fixed host-page/frame scrolling and contained reply navigation. New logins now transactionally replace prior sessions; displaced clients return to login. Added per-user session/IP locking with automatic SQLite migration. Added modular Spam Control dashboard for locks, honeypot, and CAPTCHA settings. Moved CAPTCHA controls out of Plans & Platform.
920 lines
39 KiB
PHP
920 lines
39 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());
|
|
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,
|
|
voice_bitrate_kbps INTEGER,
|
|
reply_to_id INTEGER,
|
|
reply_depth INTEGER NOT NULL DEFAULT 0,
|
|
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,
|
|
voice_bitrate_kbps,reply_to_id,reply_depth,created_at,day_key)
|
|
SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,
|
|
voice_bitrate_kbps,reply_to_id,reply_depth,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,
|
|
session_locked INTEGER NOT NULL DEFAULT 0,
|
|
session_lock_ip TEXT,
|
|
session_lock_session_id TEXT,
|
|
session_locked_at INTEGER,
|
|
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',
|
|
'session_locked' => 'INTEGER NOT NULL DEFAULT 0',
|
|
'session_lock_ip' => 'TEXT',
|
|
'session_lock_session_id' => 'TEXT',
|
|
'session_locked_at' => '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,
|
|
voice_bitrate_kbps INTEGER,
|
|
reply_to_id INTEGER,
|
|
reply_depth INTEGER NOT NULL DEFAULT 0,
|
|
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',
|
|
'voice_bitrate_kbps' => 'INTEGER',
|
|
'reply_to_id' => 'INTEGER',
|
|
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
|
|
]);
|
|
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
|
|
)");
|
|
$db->exec("CREATE TABLE IF NOT EXISTS auth_captcha_challenges (
|
|
id TEXT PRIMARY KEY,
|
|
operation TEXT NOT NULL,
|
|
answer_hash TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL
|
|
)");
|
|
$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_msg_reply ON messages(reply_to_id)");
|
|
$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 INDEX IF NOT EXISTS idx_auth_captcha_expires ON auth_captcha_challenges(expires_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, 'voice_bitrate_kbps' => 64,
|
|
'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, 'voice_bitrate_kbps' => 96,
|
|
'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, 'voice_bitrate_kbps' => 128,
|
|
'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 authCaptchaEnabledFor(string $operation): bool {
|
|
if (!in_array($operation, ['register', 'login'], true)) return false;
|
|
$key = $operation === 'register' ? 'registration_enabled' : 'login_enabled';
|
|
return (bool)getConfigVal('security.captcha.' . $key, false);
|
|
}
|
|
|
|
function createAuthCaptchaChallenge(string $operation): array {
|
|
if (!in_array($operation, ['register', 'login'], true)) {
|
|
jsonResponse(['error' => 'Invalid CAPTCHA operation'], 400);
|
|
}
|
|
if (!authCaptchaEnabledFor($operation) || !getConfigVal('security.captcha.internal.enabled', false)) {
|
|
return ['enabled' => false];
|
|
}
|
|
|
|
$difficulty = (string)getConfigVal('security.captcha.internal.difficulty', 'easy');
|
|
$upper = $difficulty === 'medium' ? 25 : 9;
|
|
$left = random_int(1, $upper);
|
|
$right = random_int(1, $upper);
|
|
$answer = $left + $right;
|
|
$ttl = max(60, min(1800, (int)getConfigVal('security.captcha.internal.ttl_seconds', 300)));
|
|
$id = bin2hex(random_bytes(24));
|
|
$hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt');
|
|
$db = getDB();
|
|
$db->prepare("DELETE FROM auth_captcha_challenges WHERE expires_at<=?")->execute([time()]);
|
|
$db->prepare("INSERT INTO auth_captcha_challenges
|
|
(id,operation,answer_hash,expires_at,created_at) VALUES (?,?,?,?,?)")
|
|
->execute([$id, $operation, hash_hmac('sha256', (string)$answer, $hashKey), time() + $ttl, time()]);
|
|
|
|
return [
|
|
'enabled' => true,
|
|
'token' => $id,
|
|
'question' => "$left + $right = ?",
|
|
'expires_in' => $ttl,
|
|
];
|
|
}
|
|
|
|
function authCaptchaFailure(string $reason): never {
|
|
error_log('Authentication CAPTCHA rejected: ' . $reason);
|
|
trackEvent('captcha_failed', '/api/auth.php');
|
|
jsonResponse(['error' => 'CAPTCHA verification failed'], 403);
|
|
}
|
|
|
|
function validateAuthCaptcha(string $operation): void {
|
|
if (!authCaptchaEnabledFor($operation)) return;
|
|
|
|
if (getConfigVal('security.captcha.honeypot.enabled', true)) {
|
|
$website = trim((string)($_POST['_hp_website'] ?? ''));
|
|
$contact = filter_var($_POST['_hp_contact'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
|
if ($website !== '' || $contact) authCaptchaFailure('honeypot');
|
|
}
|
|
|
|
if (getConfigVal('security.captcha.internal.enabled', false)) {
|
|
$token = trim((string)($_POST['captcha_internal_token'] ?? ''));
|
|
$answer = trim((string)($_POST['captcha_internal_answer'] ?? ''));
|
|
if ($token === '' || $answer === '') authCaptchaFailure('internal');
|
|
$db = getDB();
|
|
$stmt = $db->prepare("SELECT * FROM auth_captcha_challenges
|
|
WHERE id=? AND operation=? AND expires_at>?");
|
|
$stmt->execute([$token, $operation, time()]);
|
|
$challenge = $stmt->fetch();
|
|
$maxAttempts = max(1, min(10, (int)getConfigVal('security.captcha.internal.max_attempts', 3)));
|
|
$hashKey = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt');
|
|
$answerHash = hash_hmac('sha256', $answer, $hashKey);
|
|
if (!$challenge || (int)$challenge['attempts'] >= $maxAttempts
|
|
|| !hash_equals((string)$challenge['answer_hash'], $answerHash)) {
|
|
if ($challenge) {
|
|
$db->prepare("UPDATE auth_captcha_challenges SET attempts=attempts+1 WHERE id=?")
|
|
->execute([$token]);
|
|
if ((int)$challenge['attempts'] + 1 >= $maxAttempts) {
|
|
$db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]);
|
|
}
|
|
}
|
|
authCaptchaFailure('internal');
|
|
}
|
|
$db->prepare("DELETE FROM auth_captcha_challenges WHERE id=?")->execute([$token]);
|
|
}
|
|
|
|
if (!getConfigVal('security.captcha.google.enabled', false)) return;
|
|
$secret = trim((string)getConfigVal('security.captcha.google.secret_key', ''));
|
|
$responseToken = trim((string)($_POST['captcha_google_token'] ?? ''));
|
|
if ($secret === '' || $responseToken === '') authCaptchaFailure('google');
|
|
if (!function_exists('curl_init')) {
|
|
throw new RuntimeException('Google reCAPTCHA requires the PHP cURL extension.');
|
|
}
|
|
|
|
$curl = curl_init('https://www.google.com/recaptcha/api/siteverify');
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query([
|
|
'secret' => $secret,
|
|
'response' => $responseToken,
|
|
'remoteip' => clientIP(),
|
|
]),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
|
|
]);
|
|
$raw = curl_exec($curl);
|
|
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
|
$error = curl_error($curl);
|
|
curl_close($curl);
|
|
if ($raw === false || $status < 200 || $status >= 300) {
|
|
error_log('Google reCAPTCHA verification failed: ' . ($error ?: 'HTTP ' . $status));
|
|
authCaptchaFailure('google');
|
|
}
|
|
$result = json_decode((string)$raw, true);
|
|
if (!is_array($result) || empty($result['success'])) authCaptchaFailure('google');
|
|
|
|
$hostname = trim((string)getConfigVal('security.captcha.google.hostname', ''));
|
|
if ($hostname !== '' && !hash_equals(strtolower($hostname), strtolower((string)($result['hostname'] ?? '')))) {
|
|
authCaptchaFailure('google');
|
|
}
|
|
if (getConfigVal('security.captcha.google.version', 'v2') === 'v3') {
|
|
$score = (float)($result['score'] ?? 0);
|
|
$minimum = max(0.0, min(1.0, (float)getConfigVal('security.captcha.google.v3_min_score', 0.5)));
|
|
if (($result['action'] ?? '') !== $operation || $score < $minimum) {
|
|
authCaptchaFailure('google');
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (!empty($user['session_locked'])) {
|
|
$lockedSession = (string)($user['session_lock_session_id'] ?? '');
|
|
$lockedIp = (string)($user['session_lock_ip'] ?? '');
|
|
if ($lockedSession === '' || $lockedIp === ''
|
|
|| !hash_equals($lockedSession, (string)$user['session_id'])
|
|
|| !hash_equals($lockedIp, clientIP())) {
|
|
if ($required) jsonResponse(['error' => 'This account is locked to another session'], 423);
|
|
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 assertUserSessionLoginAllowed(PDO $db, int $userId): void {
|
|
$stmt = $db->prepare("SELECT session_locked,session_lock_ip,session_lock_session_id FROM users WHERE id=?");
|
|
$stmt->execute([$userId]);
|
|
$lock = $stmt->fetch();
|
|
if (!$lock || empty($lock['session_locked'])) return;
|
|
|
|
$currentSession = (string)($_COOKIE['cyberchat_sid'] ?? '');
|
|
$lockedSession = (string)($lock['session_lock_session_id'] ?? '');
|
|
$lockedIp = (string)($lock['session_lock_ip'] ?? '');
|
|
if ($currentSession === '' || $lockedSession === '' || $lockedIp === ''
|
|
|| !hash_equals($lockedSession, $currentSession)
|
|
|| !hash_equals($lockedIp, clientIP())) {
|
|
jsonResponse([
|
|
'error' => 'This account is locked to its authorized session. Contact an administrator to unlock it.'
|
|
], 423);
|
|
}
|
|
}
|
|
|
|
function createUserSession(PDO $db, int $userId): void {
|
|
$sid = bin2hex(random_bytes(32));
|
|
$now = time();
|
|
$ip = clientIP();
|
|
$db->beginTransaction();
|
|
try {
|
|
$lockStmt = $db->prepare("SELECT session_locked FROM users WHERE id=?");
|
|
$lockStmt->execute([$userId]);
|
|
$sessionLocked = (bool)$lockStmt->fetchColumn();
|
|
$db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)")
|
|
->execute([$sid, $userId, $ip, $now, $now]);
|
|
$db->prepare("DELETE FROM sessions WHERE user_id=? AND id<>?")->execute([$userId, $sid]);
|
|
if ($sessionLocked) {
|
|
$db->prepare("UPDATE users SET last_seen=?,session_lock_ip=?,session_lock_session_id=?,session_locked_at=?
|
|
WHERE id=?")->execute([$now, $ip, $sid, $now, $userId]);
|
|
} else {
|
|
$db->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([$now, $userId]);
|
|
}
|
|
$db->commit();
|
|
} catch (Throwable $e) {
|
|
if ($db->inTransaction()) $db->rollBack();
|
|
throw $e;
|
|
}
|
|
$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,voice_bitrate_kbps INTEGER,reply_to_id INTEGER,
|
|
reply_depth INTEGER NOT NULL DEFAULT 0,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',
|
|
'voice_bitrate_kbps' => 'INTEGER', 'reply_to_id' => 'INTEGER',
|
|
'reply_depth' => 'INTEGER NOT NULL DEFAULT 0',
|
|
]);
|
|
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)");
|
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_reply ON messages(reply_to_id)");
|
|
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,
|
|
voice_bitrate_kbps,reply_to_id,reply_depth,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['voice_bitrate_kbps'], $row['reply_to_id'],
|
|
$row['reply_depth'], $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 m.*,p.username AS reply_username,p.message AS reply_message,
|
|
p.message_type AS reply_message_type
|
|
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id
|
|
WHERE m.user_id=? AND m.created_at>=?";
|
|
$params = [$user['id'], $cutoff];
|
|
if (!$includeVoice) $sql .= " AND m.message_type='text'";
|
|
if ($search !== '') { $sql .= " AND m.message LIKE ?"; $params[] = '%' . $search . '%'; }
|
|
$stmt = $db->prepare($sql . " ORDER BY m.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 m.*,p.username AS reply_username,p.message AS reply_message,
|
|
p.message_type AS reply_message_type
|
|
FROM messages m LEFT JOIN messages p ON p.id=m.reply_to_id
|
|
WHERE m.user_id=? AND m.created_at>=?";
|
|
$archiveParams = [$user['id'], $cutoff];
|
|
if (!$includeVoice) $archiveSql .= " AND m.message_type='text'";
|
|
if ($search !== '') { $archiveSql .= " AND m.message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
|
|
$archiveStmt = $archive->prepare($archiveSql . " ORDER BY m.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;
|
|
}
|
|
}
|