<?php
declare(strict_types=1);
use app\optional\Cron;
use app\optional\Plugin;
use app\optional\Setup;
ini_set('display_errors', '0');
error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
date_default_timezone_set('Asia/Shanghai');
define('APP_START_TIME', microtime(true));
require __DIR__ . '/app/version.php';
define('SQL_DEBUG_MODE', false);
define('APP_ROOT', __DIR__);
define('APP_DIR', APP_ROOT . '/app');
define('ASSET_DIR', APP_DIR . '/assets');
define('DATA_DIR', APP_DIR . '/data');
define('DB_CONFIG_FILE', DATA_DIR . '/db.php');
define('INSTALL_LOCK_FILE', DATA_DIR . '/install.lock');
define('AVATAR_DIR', APP_DIR . '/avatars');
define('UPLOAD_DIR', APP_DIR . '/upload');
define('PLUGIN_DIR', APP_DIR . '/plugins');
define('PLUGIN_CSS_FILE', ASSET_DIR . '/plugins.css');
define('PLUGIN_JS_FILE', ASSET_DIR . '/plugins.js');
define('CRON_LOG_RETENTION_SECONDS', 604800);
define('CRON_LEASE_SECONDS', 1800);
define('DEBUG_LOG_FILE', DATA_DIR . '/debug.log');
define('DEBUG_LOG_DEDUP_SECONDS', 600);
define('UPDATE_STATE_FILE', DATA_DIR . '/update-state.json');
define('PASSWORD_MIN_LENGTH', 4);
define('COOKIE_TTL', 15552000);
define('AUTH_COOKIE_NAME', 'bbs_auth');
define('AUTH_COOKIE_TTL', COOKIE_TTL);
define('CSRF_COOKIE_NAME', 'bbs_csrf');
define('APP_PROJECT_URL', 'https://bbs1.org');
spl_autoload_register(static function (string $class_name): void {
    $class_file = APP_ROOT . '/' . str_replace('\\', '/', $class_name) . '.php';
    if (is_file($class_file)) require_once $class_file;
});
function app_db_config(string $file, string $data_dir): array
{
    $config = is_file($file) ? include $file : [];
    $config = is_array($config) ? $config : [];
    if (!isset($config['driver']) && isset($config['db_file'])) $config['driver'] = 'sqlite';
    $driver = in_array((string)($config['driver'] ?? 'sqlite'), ['sqlite', 'mysql', 'pgsql'], true) ? (string)($config['driver'] ?? 'sqlite') : 'sqlite';
    if ($driver === 'sqlite') {
        $name = basename((string)($config['database'] ?? $config['db_file'] ?? 'forum.sqlite'));
        if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*\.sqlite$/', $name)) $name = 'forum.sqlite';
        return ['driver' => 'sqlite', 'database' => $name, 'path' => $data_dir . '/' . $name];
    }
    return [
        'driver' => $driver,
        'host' => trim((string)($config['host'] ?? '127.0.0.1')),
        'port' => (int)($config['port'] ?? ($driver === 'mysql' ? 3306 : 5432)),
        'database' => trim((string)($config['database'] ?? '')),
        'username' => (string)($config['username'] ?? ''),
        'password' => (string)($config['password'] ?? ''),
    ];
}
function app_db_connect(array $config): PDO
{
    $driver = (string)$config['driver'];
    if (!class_exists('PDO') || !in_array($driver, PDO::getAvailableDrivers(), true)) {
        throw new RuntimeException('当前 PHP 环境缺少 PDO ' . $driver . ' 驱动。');
    }
    if ($driver === 'sqlite') {
        $dir = dirname((string)$config['path']);
        if (!is_dir($dir)) mkdir($dir, 0755, true);
        $dsn = 'sqlite:' . $config['path'];
        $username = $password = null;
    } elseif ($driver === 'mysql') {
        $dsn = 'mysql:host=' . $config['host'] . ';port=' . $config['port'] . ';dbname=' . $config['database'] . ';charset=utf8mb4';
        $username = $config['username'];
        $password = $config['password'];
    } else {
        $dsn = 'pgsql:host=' . $config['host'] . ';port=' . $config['port'] . ';dbname=' . $config['database'];
        $username = $config['username'];
        $password = $config['password'];
    }
    $db = new PDO($dsn, $username, $password, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
    if ($driver === 'sqlite') {
        foreach (['PRAGMA journal_mode=WAL', 'PRAGMA synchronous=NORMAL', 'PRAGMA temp_store=MEMORY', 'PRAGMA busy_timeout=5000', 'PRAGMA foreign_keys=ON'] as $sql) $db->exec($sql);
    } elseif ($driver === 'mysql') {
        $db->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
    } else {
        $db->exec("SET client_encoding TO 'UTF8'");
    }
    return $db;
}
function app_db_identifier(string $driver, string $name): string
{
    if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) throw new InvalidArgumentException('无效的数据库标识符。');
    return $driver === 'mysql' ? '`' . $name . '`' : '"' . $name . '"';
}
function sql_marks(int $count): string
{
    return implode(',', array_fill(0, $count, '?'));
}
function app_db_types(?string $driver = null): array
{
    $driver ??= db_driver();
    return [
        'id' => match ($driver) {
            'mysql' => 'INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT',
            'pgsql' => 'INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY',
            default => 'INTEGER PRIMARY KEY AUTOINCREMENT',
        },
        'uint' => $driver === 'mysql' ? 'INTEGER UNSIGNED' : 'INTEGER',
        'key' => $driver === 'mysql' ? 'VARCHAR(191)' : 'TEXT',
        'string' => $driver === 'mysql' ? 'VARCHAR(255)' : 'TEXT',
        'text' => $driver === 'mysql' ? 'LONGTEXT' : 'TEXT',
    ];
}
function app_db_columns(PDO $db, string $driver, string $table): array
{
    if ($driver === 'sqlite') $rows = $db->query('PRAGMA table_info(' . $table . ')')->fetchAll();
    else {
        $sql = 'SELECT column_name FROM information_schema.columns WHERE table_schema=' . ($driver === 'mysql' ? 'DATABASE()' : 'current_schema()') . ' AND table_name=?';
        $stmt = $db->prepare($sql); $stmt->execute([$table]); $rows = $stmt->fetchAll();
    }
    $columns = [];
    foreach ($rows as $row) {
        $key = $driver === 'sqlite' ? 'name' : 'column_name';
        $column = $row[$key] ?? $row[strtoupper($key)] ?? array_values($row)[0] ?? '';
        $columns[(string)$column] = true;
    }
    return $columns;
}
function app_db_table_exists(PDO $db, string $driver, string $table): bool
{
    $sql = match ($driver) {
        'mysql' => 'SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?',
        'pgsql' => 'SELECT 1 FROM information_schema.tables WHERE table_schema=current_schema() AND table_name=?',
        default => "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
    };
    $stmt = $db->prepare($sql); $stmt->execute([$table]); return (bool)$stmt->fetchColumn();
}
function app_db_index_exists(PDO $db, string $driver, string $index, string $table = ''): bool
{
    $sql = match ($driver) {
        'mysql' => 'SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND index_name=?' . ($table !== '' ? ' AND table_name=?' : ''),
        'pgsql' => 'SELECT 1 FROM pg_indexes WHERE schemaname=current_schema() AND indexname=?' . ($table !== '' ? ' AND tablename=?' : ''),
        default => "SELECT 1 FROM sqlite_master WHERE type='index' AND name=?" . ($table !== '' ? ' AND tbl_name=?' : ''),
    };
    $stmt = $db->prepare($sql); $stmt->execute($table !== '' ? [$index, $table] : [$index]); return (bool)$stmt->fetchColumn();
}
function app_db_upsert_sql(string $driver, string $table, array $columns, array $keys): string
{
    $marks = sql_marks(count($columns));
    $base = 'INSERT INTO ' . app_db_identifier($driver, $table) . '(' . implode(',', $columns) . ') VALUES(' . $marks . ')';
    $updates = array_values(array_diff($columns, $keys));
    if (!$updates) return $driver === 'mysql' ? str_replace('INSERT INTO', 'INSERT IGNORE INTO', $base) : $base . ' ON CONFLICT(' . implode(',', $keys) . ') DO NOTHING';
    if ($driver === 'mysql') return $base . ' ON DUPLICATE KEY UPDATE ' . implode(',', array_map(fn($c) => $c . '=VALUES(' . $c . ')', $updates));
    return $base . ' ON CONFLICT(' . implode(',', $keys) . ') DO UPDATE SET ' . implode(',', array_map(fn($c) => $c . '=excluded.' . $c, $updates));
}
function app_db_write(string $table, array $data, array $keys, bool $ignore = false): void
{
    if (!$data || !$keys) throw new InvalidArgumentException('数据库写入参数不能为空。');
    $columns = array_keys($data);
    if ($ignore) {
        $marks = sql_marks(count($columns));
        $base = 'INSERT INTO ' . app_db_identifier(db_driver(), $table) . '(' . implode(',', $columns) . ') VALUES(' . $marks . ')';
        $sql = db_driver() === 'mysql' ? str_replace('INSERT INTO', 'INSERT IGNORE INTO', $base) : $base . ' ON CONFLICT(' . implode(',', $keys) . ') DO NOTHING';
    } else $sql = app_db_upsert_sql(db_driver(), $table, $columns, $keys);
    db()->prepare($sql)->execute(array_values($data));
    db_row_cache_clear();
}
function app_db_upsert(string $table, array $data, array $keys): void
{
    app_db_write($table, $data, $keys);
}
function app_db_insert_ignore(string $table, array $data, array $keys): void
{
    app_db_write($table, $data, $keys, true);
}
function app_db_create_index(string $name, string $target): void
{
    $table = trim((string)strstr($target, '(', true));
    if (app_db_index_exists(db(), db_driver(), $name, $table)) return;
    db()->exec('CREATE INDEX ' . app_db_identifier(db_driver(), $name) . ' ON ' . $target);
}
function app_db_mysql_nullable_lob_defaults(string $definition, bool $with_column_names = true): string
{
    $lob = '(?:TINY|MEDIUM|LONG)?(?:TEXT|BLOB)|JSON|GEOMETRY|POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION';
    $prefix = $with_column_names ? '`?[a-zA-Z0-9_]+`?\\s+' : '';
    $pattern = "/(^|,)\\s*({$prefix}(?:{$lob})(?:\\([^)]*\\))?[^,]*?)\\s+DEFAULT\\s*(?:''|NULL)/is";
    return preg_replace_callback($pattern, static function (array $match): string {
        $col = preg_replace('/\\s+NOT\\s+NULL\\b/i', ' NULL', $match[2]) ?? $match[2];
        return $match[1] . $col;
    }, $definition) ?? $definition;
}
function app_db_drop_index(string $name, string $table): void
{
    if (!app_db_index_exists(db(), db_driver(), $name, $table)) return;
    $sql = 'DROP INDEX ' . app_db_identifier(db_driver(), $name);
    if (db_driver() === 'mysql') $sql .= ' ON ' . app_db_identifier(db_driver(), $table);
    db()->exec($sql);
}
function app_db_create_table(string $table, string $definition): void
{
    if (db_driver() === 'mysql') $definition = app_db_mysql_nullable_lob_defaults($definition);
    $sql = 'CREATE TABLE IF NOT EXISTS ' . app_db_identifier(db_driver(), $table) . '(' . $definition . ')';
    if (db_driver() === 'mysql') $sql .= ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci';
    db()->exec($sql);
}
function app_db_create_fts5_table(PDO $db, string $table, string $columns, bool $if_not_exists = true): bool
{
    $create = 'CREATE VIRTUAL TABLE ' . ($if_not_exists ? 'IF NOT EXISTS ' : '') . app_db_identifier('sqlite', $table) . ' USING fts5(' . $columns;
    try {
        $db->exec($create . ", tokenize='trigram')");
        return true;
    } catch (PDOException $e) {
        $message = strtolower($e->getMessage());
        if (!str_contains($message, 'no such tokenizer') && !str_contains($message, 'no such module: fts5')) throw $e;
        return false;
    }
}
function app_db_drop_table(string $table): void
{
    db()->exec('DROP TABLE IF EXISTS ' . app_db_identifier(db_driver(), $table));
}
function app_db_ensure_columns(string $table, array $definitions): void
{
    $columns = app_db_columns(db(), db_driver(), $table);
    foreach ($definitions as $name => $definition) {
        if (isset($columns[$name])) continue;
        if (db_driver() === 'mysql') $definition = app_db_mysql_nullable_lob_defaults($definition, false);
        db()->exec('ALTER TABLE ' . app_db_identifier(db_driver(), $table) . ' ADD COLUMN ' . app_db_identifier(db_driver(), $name) . ' ' . $definition);
    }
}
function app_db_drop_column(string $table, string $column): void
{
    $db = db();
    $driver = db_driver();
    if (!isset(app_db_columns($db, $driver, $table)[$column])) return;
    $db->exec('ALTER TABLE ' . app_db_identifier($driver, $table) . ' DROP COLUMN ' . app_db_identifier($driver, $column));
}
function app_db_last_insert_id(string $table): int
{
    if (db_driver() === 'pgsql') {
        $stmt = db()->prepare("SELECT currval(pg_get_serial_sequence(?, 'id'))");
        $stmt->execute([$table]);
        $id = (int)$stmt->fetchColumn();
    } else {
        $id = (int)db()->lastInsertId();
    }
    return $id;
}
function app_db_greatest(string ...$expressions): string
{
    return (db_driver() === 'sqlite' ? 'MAX' : 'GREATEST') . '(' . implode(',', $expressions) . ')';
}
function db_config(): array
{
    static $config;
    return $config ??= app_db_config(DB_CONFIG_FILE, DATA_DIR);
}
function db_driver(): string { return (string)db_config()['driver']; }
function db(): PDO
{
    static $db;
    if ($db) return $db;
    return $db = app_db_connect(db_config());
}
function h(string|int|float|bool|null $s): string
{
    return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
}
function sql_query_count(bool $increment = false): int
{
    static $count = 0;
    if ($increment) $count++;
    return $count;
}
function db_row_cache_clear(): void
{
    $GLOBALS['__db_row_cache'] = [];
}
function row(string $table, string $key, mixed $value): ?array
{
    $cache =& $GLOBALS['__db_row_cache'];
    if (!is_array($cache)) $cache = [];
    $cache_key = $table . "\0" . $key . "\0" . serialize($value);
    if (!array_key_exists($cache_key, $cache)) {
        $table = app_db_identifier(db_driver(), $table);
        $key = app_db_identifier(db_driver(), $key);
        $cache[$cache_key] = one("SELECT * FROM $table WHERE $key=?", [$value]) ?: false;
    }
    return $cache[$cache_key] ?: null;
}
function q(string $sql, array $p = []): PDOStatement
{
    if (strncasecmp(ltrim($sql), 'SELECT', 6) !== 0) db_row_cache_clear();
    sql_query_count(true);
    $s = db()->prepare($sql);
    $s->execute($p);
    if (SQL_DEBUG_MODE) $GLOBALS['__sql_queries'][] = [$sql, $p];
    return $s;
}
function one(string $sql, array $p = []): ?array
{
    $r = q($sql, $p)->fetch();
    return $r ?: null;
}
function val(string $sql, array $p = []): mixed
{
    return q($sql, $p)->fetchColumn();
}
function tx(callable $fn): mixed
{
    $db = db();
    if ($db->inTransaction()) return $fn();
    $sqlite = db_driver() === 'sqlite';
    if ($sqlite) $db->exec('BEGIN IMMEDIATE');
    else $db->beginTransaction();
    try {
        $result = $fn();
        if ($sqlite) $db->exec('COMMIT');
        else $db->commit();
        return $result;
    } catch (Throwable $e) {
        if ($sqlite) {
            try { $db->exec('ROLLBACK'); } catch (Throwable) {}
        } elseif ($db->inTransaction()) $db->rollBack();
        db_row_cache_clear();
        throw $e;
    }
}
function auth_cookie_secure(): bool
{
    return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
}
function app_cookie(string $name, string $value, int $expires, bool $httponly = true, bool $secure = true): void
{
    setcookie($name, $value, ['expires' => $expires, 'path' => '/', 'secure' => $secure && auth_cookie_secure(), 'httponly' => $httponly, 'samesite' => 'Lax']);
}
function auth_cookie_clear(): void
{
    app_cookie(AUTH_COOKIE_NAME, '', time() - 3600);
    unset($_COOKIE[AUTH_COOKIE_NAME]);
}
function csrf_cookie_clear(): void
{
    app_cookie(CSRF_COOKIE_NAME, '', time() - 3600);
    unset($_COOKIE[CSRF_COOKIE_NAME]);
}
function auth_cookie_set(int $user_id, string $password_hash): void
{
    $expire = time() + AUTH_COOKIE_TTL;
    $payload = $user_id . '|' . $expire;
    $signature = hash_hmac('sha256', $payload, $password_hash);
    app_cookie(AUTH_COOKIE_NAME, $user_id . '.' . $expire . '.' . $signature, $expire);
}
function auth_cookie_parts(): ?array
{
    $value = (string)($_COOKIE[AUTH_COOKIE_NAME] ?? '');
    if (!preg_match('/^(\d+)\.(\d+)\.([a-f0-9]{64})$/D', $value, $m)) return null;
    $id = (int)$m[1];
    $expire = (int)$m[2];
    return $id > 0 && $expire > 0 ? ['id' => $id, 'expire' => $expire, 'signature' => $m[3]] : null;
}
function csrf_token(): string
{
    static $token = null;
    if ($token !== null) return $token;
    $token = (string)($_COOKIE[CSRF_COOKIE_NAME] ?? '');
    if (!preg_match('/^[a-f0-9]{64}$/D', $token)) {
        $token = bin2hex(random_bytes(32));
        app_cookie(CSRF_COOKIE_NAME, $token, time() + COOKIE_TTL);
        $_COOKIE[CSRF_COOKIE_NAME] = $token;
    }
    return $token;
}
function rows_by_ids(string $table, array $ids, string $cols = '*'): array
{
    $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
    if (!$ids) return [];
    if (in_array($table, ['app_users', 'app_topics', 'app_replies'], true) && count($ids) === 1) {
        $row = row($table, 'id', $ids[0]);
        if (!$row) return [];
        return [$ids[0] => $row];
    }
    $marks = sql_marks(count($ids));
    $rows = q("SELECT $cols FROM $table WHERE id IN ($marks)", $ids)->fetchAll();
    $map = [];
    foreach ($rows as $row) $map[(int)$row['id']] = $row;
    return $map;
}
function user_summary_defaults(string $username = ''): array
{
    return ['username' => $username, 'avatar_style' => '', 'avatar_seed' => '', 'group_id' => 0, 'points' => 0, 'is_banned' => 0, 'is_muted' => 0];
}
function attach_users(array $rows, string $key = 'user_id', string $fallback = '用户删除'): array
{
    $users = rows_by_ids('app_users', array_column($rows, $key), 'id,username,avatar_style,avatar_seed,group_id,points,is_banned,is_muted');
    foreach ($rows as &$row) $row += ($users[(int)($row[$key] ?? 0)] ?? user_summary_defaults($fallback));
    unset($row);
    return $rows;
}
function attach_topic_list_users(array $rows): array
{
    $user_ids = array_merge(array_column($rows, 'user_id'), array_column($rows, 'last_reply_user_id'));
    $users = rows_by_ids('app_users', $user_ids, 'id,username,avatar_style,avatar_seed,group_id,points,is_banned,is_muted');
    foreach ($rows as &$row) {
        $row += ($users[(int)($row['user_id'] ?? 0)] ?? user_summary_defaults());
        $last_reply_uid = (int)($row['last_reply_user_id'] ?? 0);
        $row['last_reply_username'] = $last_reply_uid > 0 ? (string)($users[$last_reply_uid]['username'] ?? '') : '';
    }
    unset($row);
    return $rows;
}
function db_schema_ready(): bool
{
    return is_file(INSTALL_LOCK_FILE);
}
function default_settings(): array
{
    return [
        'site_name' => 'FORUM',
        'allow_register' => '1',
        'default_group_id' => '2',
        'pc_nav_forum_count' => '6',
        'topics_per_page' => '30',
        'replies_per_page' => '50',
        'max_pagination_pages' => '50',
        'search_min_chars' => '2',
        'mysql_search_index_topics_title' => '0',
        'mysql_search_index_topics_body' => '0',
        'mysql_search_index_replies_body' => '0',
        'post_interval_seconds' => '5',
        'stats_topics' => '0',
        'stats_replies' => '0',
        'stats_users' => '0',
        'plugin_sync_pending' => '0',
        'plugin_assets_dirty' => '1',
        'plugin_assets_css_hash' => '',
        'plugin_assets_css_size' => '0',
        'plugin_assets_js_hash' => '',
        'plugin_assets_js_size' => '0',
        'cron_logs_pruned_at' => '0',
    ];
}
function settings_cache(): array
{
    return $GLOBALS['__settings_cache'] ??= array_merge(default_settings(), array_column(q("SELECT name,value FROM app_settings")->fetchAll(), 'value', 'name'));
}
function setting(string $key, string $default = ''): string
{
    $settings = settings_cache();
    return (string)($settings[$key] ?? $default);
}
function save_settings_values(array $values): void
{
    $stmt = db()->prepare(app_db_upsert_sql(db_driver(), 'app_settings', ['name', 'value'], ['name']));
    foreach ($values as $name => $value) $stmt->execute([$name, $value]);
    db_row_cache_clear();
    if (is_array($GLOBALS['__settings_cache'] ?? null)) {
        foreach ($values as $name => $value) $GLOBALS['__settings_cache'][(string)$name] = (string)$value;
    }
    foreach (array_keys($values) as $name) {
        if (str_starts_with((string)$name, 'plugin_')) {
            plugin_runtime_cache_reset();
            break;
        }
    }
}
function settings_rows_cache(string $key, string $sql, bool $refresh): array
{
    $rows = $refresh ? null : json_decode(setting($key), true);
    if (is_array($rows)) return $rows;
    $rows = q($sql)->fetchAll();
    save_settings_values([$key => json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
    return $rows;
}
function exception_detail(Throwable $e): string
{
    $parts = [];
    do {
        $parts[] = get_class($e) . ': ' . $e->getMessage() . "\n" . $e->getFile() . ':' . $e->getLine() . "\n" . $e->getTraceAsString();
        $e = $e->getPrevious();
    } while ($e);
    return implode("\n\nPrevious:\n", $parts);
}
function debug_mode_enabled(): bool
{
    try {
        return db_schema_ready() && setting('debug_mode', '0') === '1';
    } catch (Throwable $e) {
        return false;
    }
}
function debug_log_write(string $message, ?Throwable $e = null): void
{
    if (!debug_mode_enabled()) return;
    Setup::debug_log_write($message, $e);
}
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
    if (error_reporting() === 0) return false;
    debug_log_write('PHP error [' . $severity . '] ' . $message . "\n" . $file . ':' . $line);
    return false;
});
register_shutdown_function(function (): void {
    $error = error_get_last();
    if (!$error || !in_array((int)$error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) return;
    debug_log_write('PHP fatal [' . (int)$error['type'] . '] ' . (string)$error['message'] . "\n" . (string)$error['file'] . ':' . (int)$error['line']);
});
function plugin_id_valid(string $id): bool
{
    return preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $id) === 1;
}
function plugin_runtime_cache_reset(): void
{
    unset($GLOBALS['__hook_registry']);
}
function plugin_json_decode(mixed $json, ?array $fallback = []): ?array
{
    if (is_array($json)) return $json;
    $value = json_decode((string)$json, true);
    return is_array($value) ? $value : $fallback;
}
function plugin_json_encode(array $value): string
{
    return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
function plugin_update_row(string $id, array $values, bool $touch_updated_at = true): void
{
    if (!plugin_id_valid($id)) throw new InvalidArgumentException('插件 ID 无效');
    $allowed = array_flip(['name', 'version', 'file', 'code_hash', 'manifest_json', 'config_json', 'entries_json', 'enabled', 'status', 'disabled_reason', 'installed_at']);
    if (array_diff_key($values, $allowed)) throw new InvalidArgumentException('插件字段无效');
    foreach (['manifest_json', 'config_json', 'entries_json'] as $field) {
        if (array_key_exists($field, $values)) $values[$field] = plugin_json_encode(plugin_json_decode($values[$field]) ?? []);
    }
    if (!$values) return;
    if ($touch_updated_at) $values['updated_at'] = now();
    $fields = array_keys($values);
    q('UPDATE app_plugins SET ' . implode('=?,', $fields) . '=? WHERE id=?', array_merge(array_values($values), [$id]));
}
function plugin_registry_row(array $row): ?array
{
    $id = (string)($row['id'] ?? '');
    $file = str_replace('\\', '/', ltrim((string)($row['file'] ?? ''), '/'));
    $manifest = plugin_json_decode($row['manifest_json'] ?? '', null);
    if (!plugin_id_valid($id) || $file !== 'app/plugins/' . $id . '/plugin.php' || $manifest === null) return null;
    return array_merge($manifest, [
        'id' => $id,
        'name' => (string)$row['name'],
        'version' => (string)$row['version'],
        'enabled' => (int)$row['enabled'] === 1,
        'disabled_reason' => (string)($row['disabled_reason'] ?? ''),
        'updated_at' => (int)($row['updated_at'] ?? 0),
        'file' => APP_ROOT . '/' . $file,
        'config' => plugin_json_decode($row['config_json'] ?? '') ?? [],
        'entries' => plugin_json_decode($row['entries_json'] ?? '') ?? [],
    ]);
}
function plugin_load(array $plugin): void
{
    $file = (string)($plugin['file'] ?? '');
    if ($file === '' || !is_file($file)) return;
    if (array_key_exists($file, $GLOBALS['__plugin_raw'] ?? [])) return;
    $GLOBALS['__plugin_raw'][$file] = include $file;
}
function plugin_callback_exists(mixed $callback): bool
{
    return is_string($callback) && $callback !== '' && function_exists($callback);
}
function plugin_call(array $plugin, callable $callback): mixed
{
    try {
        plugin_load($plugin);
        return $callback();
    } catch (Throwable $e) {
        Plugin::plugin_disable_after_exception((string)($plugin['id'] ?? ''), $e);
        throw $e;
    }
}
function plugins(bool $refresh = false): array
{
    static $plugins = null;
    if (!$refresh && $plugins !== null) return $plugins;
    $plugins = [];
    foreach (Plugin::plugin_runtime_cache_rows($refresh) as $plugin) {
        $id = (string)$plugin['id'];
        $plugin['file'] = APP_ROOT . '/' . (string)$plugin['file'];
        $plugin['enabled'] = true;
        $plugins[$id] = $plugin;
    }
    plugin_runtime_cache_reset();
    return $plugins;
}
function plugin_enabled(array $plugin): bool
{
    return !empty($plugin['enabled']);
}
function plugin_assets_manifest(): array
{
    static $manifest;
    if (is_array($manifest)) return $manifest;
    if (setting('plugin_assets_dirty', '1') === '1' || !is_file(PLUGIN_CSS_FILE) || !is_file(PLUGIN_JS_FILE)) {
        try { return $manifest = Plugin::plugin_assets_rebuild(); }
        catch (Throwable $e) { debug_log_write('插件资源生成失败', $e); return $manifest = []; }
    }
    return $manifest = [
        'css' => setting('plugin_assets_css_hash'),
        'css_size' => (int)setting('plugin_assets_css_size'),
        'js' => setting('plugin_assets_js_hash'),
        'js_size' => (int)setting('plugin_assets_js_size'),
    ];
}
function plugin_asset_tag(string $type): string
{
    $manifest = plugin_assets_manifest();
    if (!in_array($type, ['css', 'js'], true) || (int)($manifest[$type . '_size'] ?? 0) < 1) return '';
    $version = substr((string)($manifest[$type] ?? ''), 0, 12);
    $file = 'app/assets/plugins.' . $type;
    if ($type === 'css') return '<link rel="stylesheet" href="' . h(asset_url($file)) . '?v=' . h($version) . '">';
    return '<script src="' . h(asset_url($file)) . '?v=' . h($version) . '" defer></script>';
}
function plugin_entry_definitions(): array
{
    return [
        'feature_links' => ['hook' => 'sidebar.feature_links', 'label' => '快捷功能'],
        'sidebar_cards' => ['hook' => 'sidebar.stack', 'label' => '边栏卡片'],
        'home_tabs' => ['hook' => 'topic.index_tabs', 'label' => '首页Tab'],
        'profile_tabs' => ['hook' => 'user.profile_tabs', 'label' => '个人主页Tab'],
        'profile_card' => ['hook' => 'user.menu_links', 'label' => '个人卡片'],
        'topic_actions' => ['hook' => 'topic.actions', 'label' => '主题操作'],
        'admin_tabs' => ['hook' => 'admin.tabs', 'label' => '后台Tab'],
        'top_menu' => ['hook' => 'top.menu_links', 'label' => '顶部菜单'],
    ];
}
function plugin_entry_hook_name(string $entry): string
{
    return (string)(plugin_entry_definitions()[$entry]['hook'] ?? '');
}
function plugin_uses_entry(array $plugin, string $entry): bool
{
    $hook = plugin_entry_hook_name($entry);
    return $hook !== '' && isset($plugin['hooks'][$hook]);
}
function plugin_entry_enabled(array $plugin, string $entry): bool
{
    if (!plugin_uses_entry($plugin, $entry)) return false;
    return !array_key_exists($entry, (array)($plugin['entries'] ?? [])) || !empty($plugin['entries'][$entry]);
}
function plugin_config(string $id, array $defaults = []): array
{
    if (!plugin_id_valid($id)) return $defaults;
    $plugin = plugins()[$id] ?? null;
    if ($plugin) return array_merge($defaults, (array)($plugin['config'] ?? []));
    $row = one("SELECT config_json FROM app_plugins WHERE id=?", [$id]);
    return array_merge($defaults, $row ? plugin_json_decode($row['config_json'] ?? '') ?? [] : []);
}
function plugin_save_config(string $id, array $config): void
{
    if (!plugin_id_valid($id)) err('插件不存在');
    plugin_update_row($id, ['config_json' => $config]);
    $plugin = plugins(true)[$id] ?? null;
    if ($plugin) Cron::plugin_cron_sync($plugin);
}
function require_writable_dir(string $dir, string $message): void
{
    if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) err($message);
    if (!is_writable($dir)) err($message);
    $probe = @tempnam($dir, '.write-test-');
    if ($probe === false || @file_put_contents($probe, '1', LOCK_EX) === false) {
        if ($probe !== false) @unlink($probe);
        err($message);
    }
    @unlink($probe);
}
function hook_registry(): array
{
    if (is_array($GLOBALS['__hook_registry'] ?? null)) return $GLOBALS['__hook_registry'];
    $registry = [];
    foreach (plugins() as $plugin) {
        if (!plugin_enabled($plugin)) continue;
        foreach ($plugin['hooks'] as $name => $fn) {
            foreach (plugin_entry_definitions() as $entry => $definition) {
                if ($name === $definition['hook'] && !plugin_entry_enabled($plugin, $entry)) continue 2;
            }
            $registry[$name][] = ['plugin' => $plugin, 'fn' => $fn];
        }
    }
    return $GLOBALS['__hook_registry'] = $registry;
}
function hook(string $name, mixed $value = null, array $ctx = []): mixed
{
    $registry = $GLOBALS['__hook_registry'] ?? hook_registry();
    $entries = $registry[$name] ?? null;
    if (!$entries) return $value;
    foreach ($entries as $entry) {
        $plugin = $entry['plugin'];
        $fn = $entry['fn'];
        $next = plugin_call($plugin, fn(): mixed => plugin_callback_exists($fn) ? $fn($value, $ctx) : null);
        if ($next !== null) $value = $next;
    }
    return $value;
}
function fire(string $name, array $ctx = []): void
{
    hook($name, null, $ctx);
}
function plugin_route(string $action): bool
{
    foreach (plugins() as $plugin) {
        if (!plugin_enabled($plugin)) continue;
        $fn = $plugin['routes'][$action] ?? null;
        if ($fn !== null) {
            $handled = plugin_call($plugin, function () use ($fn, $plugin): bool {
                if (!plugin_callback_exists($fn)) return false;
                $fn($plugin);
                return true;
            });
            if ($handled) return true;
        }
    }
    return false;
}
function pinned_topic_ids(): array
{
    return array_values(array_unique(array_filter(array_map('intval', preg_split('/\s*,\s*/', setting('pinned_topic_ids'), -1, PREG_SPLIT_NO_EMPTY) ?: []))));
}
function set_pinned_topic(int $tid, bool $pin): void
{
    $ids = pinned_topic_ids();
    $ids = $pin ? array_values(array_unique(array_merge([$tid], $ids))) : array_values(array_diff($ids, [$tid]));
    save_settings_values(['pinned_topic_ids' => implode(',', $ids)]);
}
function clean_ip(string $value): string
{
    $value = trim($value, " \t\n\r\0\x0B\"'");
    if ($value === '') return '';
    if (in_array(strtolower($value), ['unknown', 'null', 'undefined'], true)) return '';
    if (($p = strpos($value, ';')) !== false) $value = substr($value, 0, $p);
    if (stripos($value, 'for=') === 0) $value = substr($value, 4);
    $value = trim($value, " \t\n\r\0\x0B\"'");
    if (str_starts_with($value, '[')) {
        $end = strpos($value, ']');
        if ($end === false) return '';
        $port = substr($value, $end + 1);
        if ($port !== '' && !preg_match('/^:\d+$/D', $port)) return '';
        $value = substr($value, 1, $end - 1);
    } elseif (substr_count($value, ':') === 1) {
        [$host, $port] = explode(':', $value, 2);
        if ($port !== '' && ctype_digit($port)) $value = $host;
    }
    if (($p = strpos($value, '%')) !== false) $value = substr($value, 0, $p);
    $packed = inet_pton($value);
    if ($packed === false) return '';
    $normalized = inet_ntop($packed);
    return is_string($normalized) ? strtolower($normalized) : '';
}
function ip_addr(): string
{
    foreach (['HTTP_CLIENT_IP', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR'] as $key) {
        foreach (explode(',', (string)($_SERVER[$key] ?? '')) as $value) {
            $ip = clean_ip($value);
            if ($ip !== '') return $ip;
        }
    }
    return '0.0.0.0';
}
function post_interval_seconds(): int
{
    return min(3600, max(0, (int)setting('post_interval_seconds', '5')));
}
function check_post_interval(): void
{
    $seconds = post_interval_seconds();
    if ($seconds <= 0 || !uid()) return;
    $wait = $seconds - (time() - (int)(row('app_users', 'id', uid())['last_post_at'] ?? 0));
    if ($wait > 0) err('操作太频繁，请 ' . $wait . ' 秒后再试');
}
function clear_opcache_cache(): bool
{
    if (!function_exists('opcache_reset')) return false;
    try {
        return (bool)opcache_reset();
    } catch (Throwable $e) {
        return false;
    }
}
function opcache_refresh_route(): void
{
    $lock_file = DATA_DIR . '/opcache-refresh.lock';
    if (!is_file($lock_file) || !clear_opcache_cache()) {
        http_response_code(403);
        exit('failed');
    }
    @unlink($lock_file);
    exit('ok');
}
function clean_site_base_url(string $url): string
{
    $url = rtrim(trim($url), '/');
    if ($url === '') return '';
    $parts = parse_url($url);
    if (!is_array($parts)) return '';
    $scheme = strtolower((string)($parts['scheme'] ?? ''));
    $host = (string)($parts['host'] ?? '');
    if (!in_array($scheme, ['http', 'https'], true) || $host === '') return '';
    return $url;
}
function save_settings(): void
{
    $site_name = post('site_name', 80);
    if ($site_name === '') err('网站名不能为空');
    $gid = max(1, (int)($_POST['default_group_id'] ?? 2));
    if (!group_by_id($gid)) err('默认用户组不存在');
    $values = [
        'site_name' => $site_name,
        'site_base_url' => clean_site_base_url((string)($_POST['site_base_url'] ?? '')),
        'pinned_topic_ids' => preg_replace('/[^\d,]/', '', (string)($_POST['pinned_topic_ids'] ?? '')) ?: '',
        'default_group_id' => (string)$gid,
    ];
    foreach (['site_name_title' => 80, 'site_keywords' => 200, 'site_description' => 500] as $key => $max) $values[$key] = post($key, $max);
    foreach (['site_closed', 'debug_mode', 'ignore_ssl_errors', 'pretty_url', 'allow_register'] as $key) $values[$key] = isset($_POST[$key]) ? '1' : '0';
    foreach (['pc_nav_forum_count' => [0, 20, 6], 'topics_per_page' => [1, 200, 30], 'replies_per_page' => [1, 200, 50], 'max_pagination_pages' => [1, 1000, 50], 'search_min_chars' => [1, 20, 2], 'post_interval_seconds' => [0, 3600, 5]] as $key => [$min, $max, $default]) {
        $values[$key] = (string)min($max, max($min, (int)($_POST[$key] ?? $default)));
    }
    save_settings_values($values);
}
function forums_cache(bool $refresh = false): array
{
    if ($refresh) unset($GLOBALS['__forums_cache'], $GLOBALS['__forum_by_id_map']);
    return $GLOBALS['__forums_cache'] ??= settings_rows_cache('cache_forums', "SELECT id,name,description,sort,allow_view_groups,allow_post_groups,allow_reply_groups FROM app_forums ORDER BY sort,id", $refresh);
}
function forum_by_id(int $id): ?array
{
    if (!is_array($GLOBALS['__forum_by_id_map'] ?? null)) {
        $GLOBALS['__forum_by_id_map'] = [];
        foreach (forums_cache() as $forum) $GLOBALS['__forum_by_id_map'][(int)$forum['id']] = $forum;
    }
    return $GLOBALS['__forum_by_id_map'][$id] ?? null;
}
function forum_group_select_options(?array $forum = null, string $field = '', string $label = ''): string
{
    $selected = [];
    if ($forum && $field !== '') $selected = forum_group_ids($forum, $field);
    $html = '<div class="grid"><span>' . h($label) . '</span><div class="forum-group-checks">';
    foreach (groups_cache() as $g) {
        $gid = (int)$g['id'];
        $html .= '<label class="check"><input type="checkbox" name="' . h($field) . '[]" value="' . $gid . '"' . (in_array($gid, $selected, true) ? ' checked' : '') . '><span>' . h($g['name']) . '</span></label>';
    }
    return $html . '</div></div>';
}
function forum_group_ids(array $forum, string $field): array
{
    $raw = trim((string)($forum[$field] ?? ''));
    if ($raw === '') return [];
    $ids = array_values(array_unique(array_filter(array_map('intval', preg_split('/\s*,\s*/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: []))));
    return $ids;
}
function forum_group_allowed(?array $forum, string $field): bool
{
    if (!$forum) return false;
    $ids = forum_group_ids($forum, $field);
    if (!$ids) return true;
    $me = me();
    $gid = (int)($me['group_id'] ?? 0);
    if (in_array($gid, $ids, true)) return true;
    return (bool)hook('forum_group_allowed', false, ['forum_id' => (int)$forum['id'], 'field' => $field, 'gid' => $gid]);
}
function groups_cache(bool $refresh = false): array
{
    if ($refresh) unset($GLOBALS['__groups_cache'], $GLOBALS['__group_by_id_map']);
    return $GLOBALS['__groups_cache'] ??= settings_rows_cache('cache_groups', "SELECT id,name,allow_manage,allow_admin FROM app_groups ORDER BY id", $refresh);
}
function group_by_id(int $id): ?array
{
    if (!is_array($GLOBALS['__group_by_id_map'] ?? null)) {
        $GLOBALS['__group_by_id_map'] = [];
        foreach (groups_cache() as $group) $GLOBALS['__group_by_id_map'][(int)$group['id']] = $group;
    }
    return $GLOBALS['__group_by_id_map'][$id] ?? null;
}
function user_by_id(int $id): ?array
{
    if ($id > 0 && $id === uid()) return me();
    return row('app_users', 'id', $id);
}
function notification_badge_html(int $count): string
{
    return $count > 0 ? '<span class="notify-badge">' . (int)$count . '</span>' : '';
}
function content_excerpt(string $body, int $max = 120): string
{
    $body = trim(preg_replace('/\s+/u', ' ', $body) ?? '');
    return cut($body, $max);
}
function content_preview_source_text(string $body): string
{
    $body = str_replace(["\r\n", "\r"], "\n", $body);
    $lines = [];
    $in_code = false;
    foreach (explode("\n", $body) as $line) {
        if (preg_match('/^\s*```\s*[\w-]*\s*$/u', $line)) {
            $in_code = !$in_code;
            continue;
        }
        if ($in_code) continue;
        $lines[] = $line;
    }
    return implode("\n", $lines);
}
function notification_excerpt(string $body, int $max = 120): string
{
    $body = preg_replace('/^\s*>.*(?:\n|$)/mu', '', $body) ?? $body;
    return content_excerpt($body, $max);
}
function notification_targets(string $body): array
{
    if (!preg_match_all('/@([^\s@,，。！？!?；;:：<>]+)/u', $body, $m)) return [];
    $targets = [];
    foreach ($m[1] as $name) {
        $name = trim((string)$name);
        if ($name !== '') $targets[$name] = true;
    }
    return array_keys($targets);
}
function create_notification(int $recipient_id, int $sender_id, string $kind, string $content, int $topic_id = 0, int $reply_id = 0): bool
{
    $content = trim($content);
    if ($recipient_id <= 0 || $content === '') return false;
    if ($recipient_id === $sender_id && $kind !== 'direct') return false;
    $topic_id = $topic_id > 0 ? $topic_id : null;
    $reply_id = $reply_id > 0 ? $reply_id : null;
    $created_at = now();
    q("INSERT INTO app_notifications(recipient_id,sender_id,kind,content,topic_id,reply_id,created_at,read_at) VALUES(?,?,?,?,?,?,?,0)", [$recipient_id, $sender_id, $kind, $content, $topic_id, $reply_id, $created_at]);
    $notification_id = app_db_last_insert_id('app_notifications');
    q("UPDATE app_users SET unread_notifications=COALESCE(unread_notifications,0)+1 WHERE id=?", [$recipient_id]);
    fire('notification.after_create', [
        'id' => $notification_id,
        'recipient_id' => $recipient_id,
        'sender_id' => $sender_id,
        'kind' => $kind,
        'content' => $content,
        'topic_id' => $topic_id,
        'reply_id' => $reply_id,
        'created_at' => $created_at,
        'read_at' => 0,
    ]);
    return true;
}
function user_points_change(int $user_id, int $delta, string $reason = '系统调整', bool $notify = false, array $context = []): int
{
    if ($user_id <= 0 || $delta === 0) return 0;
    [$actual, $now_points] = tx(function () use ($user_id, $delta): array {
        $old = (int)(row('app_users', 'id', $user_id)['points'] ?? 0);
        $new = $old + $delta;
        $actual = $new - $old;
        if ($actual !== 0) q("UPDATE app_users SET points=? WHERE id=?", [$new, $user_id]);
        return [$actual, $new];
    });
    if ($actual === 0) return 0;
    if ($user_id === uid()) unset($GLOBALS['__me_cache']);
    $verb = $actual > 0 ? '增加' : '减少';
    $notification_message = '积分' . $verb . ' ' . abs($actual) . '，原因：' . trim($reason) . '。当前积分 ' . $now_points . '。';
    if ($notify) create_notification($user_id, 0, 'points', '你的' . $notification_message);
    elseif ($user_id === uid()) {
        $tip = '积分 ' . ($actual > 0 ? '+' : '') . $actual;
        if (ajax_request()) $GLOBALS['__point_change_tip'] = $tip;
        else set_flash($tip);
    }
    fire('user.points_changed', array_merge($context, [
        'event_key' => 'points-change:' . bin2hex(random_bytes(16)),
        'user_id' => $user_id,
        'delta' => $actual,
        'reason' => trim($reason),
        'points' => $now_points,
        'notify' => $notify,
    ]));
    return $actual;
}
function user_points_set(int $user_id, int $points, string $reason = '系统调整'): int
{
    if ($user_id <= 0) return 0;
    $old = (int)(row('app_users', 'id', $user_id)['points'] ?? 0);
    return user_points_change($user_id, $points - $old, $reason);
}
function create_mention_notifications(int $topic_id, int $reply_id, string $body, int $sender_id): void
{
    $topic = row('app_topics', 'id', $topic_id);
    if (!$topic) return;
    $usernames = notification_targets($body);
    $targets = [];
    if ($usernames) {
        $marks = sql_marks(count($usernames));
        $users = q("SELECT id FROM app_users WHERE username IN ($marks)", $usernames)->fetchAll();
        foreach ($users as $user) $targets[(int)$user['id']] = true;
    }
    unset($targets[$sender_id]);
    $excerpt = notification_excerpt($body);
    foreach (array_keys($targets) as $uid) {
        create_notification((int)$uid, $sender_id, 'mention', '在主题《' . (string)$topic['title'] . '》中提到你：' . $excerpt, $topic_id, $reply_id);
    }
}
function create_topic_notifications(int $topic_id, string $body, int $sender_id): void
{
    create_mention_notifications($topic_id, 0, $body, $sender_id);
}
function create_reply_notifications(int $topic_id, int $reply_id, string $body, int $sender_id): void
{
    create_mention_notifications($topic_id, $reply_id, $body, $sender_id);
}
function notifications_list(int $uid, int $limit, int $offset = 0): array
{
    $rows = q("SELECT * FROM app_notifications WHERE recipient_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?", [$uid, $limit, $offset])->fetchAll();
    $users = rows_by_ids('app_users', array_column($rows, 'sender_id'), 'id,username,avatar_style,avatar_seed');
    foreach ($rows as &$row) {
        $u = $users[(int)($row['sender_id'] ?? 0)] ?? null;
        $row['sender_username'] = (string)($u['username'] ?? '');
        $row['sender_avatar_style'] = (string)($u['avatar_style'] ?? '');
        $row['sender_avatar_seed'] = (string)($u['avatar_seed'] ?? '');
    }
    unset($row);
    return $rows;
}
function notifications_total(int $uid): int
{
    return (int)val("SELECT COUNT(*) FROM app_notifications WHERE recipient_id=?", [$uid]);
}
function notifications_unread_total(int $uid): int
{
    $m = me();
    if ($m && (int)$m['id'] === $uid) return (int)($m['unread_notifications'] ?? 0);
    return (int)val("SELECT COUNT(*) FROM app_notifications WHERE recipient_id=? AND read_at=0", [$uid]);
}
function mark_notifications_read(int $uid, int $unread): void
{
    if ($unread <= 0) return;
    q("UPDATE app_notifications SET read_at=? WHERE recipient_id=? AND read_at=0", [now(), $uid]);
    q("UPDATE app_users SET unread_notifications=0 WHERE id=?", [$uid]);
    if (is_array($GLOBALS['__me_cache'] ?? null) && (int)$GLOBALS['__me_cache']['id'] === $uid) $GLOBALS['__me_cache']['unread_notifications'] = 0;
}
function notification_link(array $n): string
{
    if ((int)($n['topic_id'] ?? 0) > 0) {
        return route_url('topic', ['id' => (int)$n['topic_id'], 'replyid' => (int)($n['reply_id'] ?? 0) ?: null]);
    }
    if ((int)($n['sender_id'] ?? 0) > 0) return route_url('user', ['id' => (int)$n['sender_id']]);
    return route_url('home');
}
function notification_row_html(array $n): string
{
    $sender_id = (int)($n['sender_id'] ?? 0);
    $sender_name = trim((string)($n['sender_username'] ?? '')) ?: '系统';
    $body = (string)($n['content'] ?? '');
    $content_html = markdown_html($body);
    if ((int)($n['topic_id'] ?? 0) > 0 || (int)($n['reply_id'] ?? 0) > 0) {
        $url = notification_link($n);
        if ($url !== '') {
            $linked = preg_replace_callback('/《((?:[^《》]|(?R))*)》/u', static function (array $match) use ($url): string {
                // Mentions in a topic title have already been linked by markdown_html().
                // Remove those inner anchors before making the whole title the topic link;
                // nested anchors are invalid HTML and browsers otherwise keep the user link.
                $title_html = preg_replace('/<\/?a\b[^>]*>/iu', '', $match[1]) ?? $match[1];
                return '《<a href="' . h($url) . '">' . $title_html . '</a>》';
            }, $content_html, 1, $count);
            if (is_string($linked) && $count > 0) {
                $content_html = $linked;
            } else {
                $view_link = '<a href="' . h($url) . '">查看主题</a>';
                $content_html = preg_replace('/<\/p>\s*$/u', ' ' . $view_link . '</p>', $content_html, 1, $paragraph_count) ?? $content_html;
                if ($paragraph_count < 1) $content_html .= ' ' . $view_link;
            }
        }
    }
    $kind = (string)($n['kind'] ?? '') === 'mention' ? '提及' : '通知';
    $unread = (int)($n['read_at'] ?? 0) === 0;
    $quote = notification_excerpt($body, 100);
    $action = (string)($n['kind'] ?? '') === 'direct' && $sender_id > 0 ? '<a class="post-tag post-forum-badge notification-reply-action" href="' . h(route_url('notify', ['id' => $sender_id, 'quote' => $quote])) . '" onclick="openNotify(this.href);return false">回复TA</a>' : '';
    $sender_title = $sender_id > 0 ? '<a class="post-title" href="' . h(route_url('user', ['id' => $sender_id])) . '">' . h($sender_name) . '</a>' : '<span class="post-title">' . h($sender_name) . '</span>';
    return '<li class="post-item notification-item' . ($unread ? ' unread' : '') . '"><div class="post-avatar">' . avatar_link_tag($sender_id ?: 0, $sender_name, (string)($n['sender_avatar_style'] ?? ''), '', (string)($n['sender_avatar_seed'] ?? '')) . '</div><div class="post-body"><div class="post-title-row notification-head">' . $sender_title . '<span class="post-user-group notification-kind">' . h($kind) . '</span>' . ($unread ? '<span class="notification-unread">未读</span>' : '') . '</div><div class="post-meta"><span>' . human_time((int)$n['created_at']) . '</span></div><div class="post-content notification-content">' . $content_html . '</div></div>' . $action . '</li>';
}
function admin_flag(int $yes, bool $danger = false): string
{
    return '<span class="admin-flag' . ($yes ? ($danger ? ' danger' : ' on') : '') . '">' . ($yes ? '是' : '否') . '</span>';
}
function admin_list_head(string $left = '', string $right = ''): string
{
    return '<div class="admin-list-head"><div class="admin-head-inline"><div class="admin-head-left-slot">' . $left . '</div><div class="admin-head-right-slot">' . $right . '</div></div></div>';
}
function user_state_tag_html(array $u): string
{
    $tags = [];
    if ((int)($u['is_banned'] ?? 0) === 1) $tags[] = '<span class="user-state-tag danger">禁访</span>';
    if ((int)($u['is_muted'] ?? 0) === 1) $tags[] = '<span class="user-state-tag danger">禁言</span>';
    return $tags ? '<span class="user-state-tags">' . implode('', $tags) . '</span>' : '';
}
function deletable_post_row(string $type, int $id): ?array
{
    if ($type === 'topics') return row('app_topics', 'id', $id);
    if ($type === 'replies') return row('app_replies', 'id', $id);
    return null;
}
function remember_forum(int $fid): void
{
    if (!$fid || !forum_by_id($fid)) return;
    $raw = array_map('intval', explode('.', (string)($_COOKIE['__recent_forums'] ?? '')));
    $ids = array_values(array_diff(array_filter($raw), [$fid]));
    array_unshift($ids, $fid);
    $value = implode('.', array_slice($ids, 0, 10));
    app_cookie('__recent_forums', $value, time() + COOKIE_TTL, false);
    $_COOKIE['__recent_forums'] = $value;
}
function recent_forums(): array
{
    $list = [];
    foreach (array_map('intval', explode('.', (string)($_COOKIE['__recent_forums'] ?? ''))) as $fid) {
        $f = forum_by_id($fid);
        if ($f) $list[] = $f;
    }
    return $list ?: forums_cache();
}
function mark_viewed(int $tid): bool
{
    $seen = array_values(array_unique(array_filter(array_map('intval', explode('.', (string)($_COOKIE['__viewed_topics'] ?? ''))))));
    if (in_array($tid, $seen, true)) return false;
    $seen[] = $tid;
    $value = implode('.', array_slice($seen, -64));
    app_cookie('__viewed_topics', $value, time() + COOKIE_TTL, false);
    $_COOKIE['__viewed_topics'] = $value;
    return true;
}
function quick_forums_html(): string
{
    $html = '<div class="card sidebar-card quick-card"><div class="quick-wrap"><div class="quick-title">最近浏览版块</div><ul class="quick-links quick-forum-links">';
    foreach (array_slice(recent_forums(), 0, 10) as $f) $html .= '<li><a href="' . h(route_url('forum', ['id' => (int)$f['id']])) . '"><span class="quick-link-text">' . h($f['name']) . '</span></a></li>';
    return $html . '</ul></div></div>';
}
function sidebar_notice_card_html(string $title, array $items): string
{
    $html = '<div class="card sidebar-card quick-card"><div class="quick-wrap"><div class="quick-title">' . h($title) . '</div><ul class="quick-links notice-links">';
    foreach ($items as $item) $html .= '<li>' . h($item) . '</li>';
    return $html . '</ul></div></div>';
}
function is_home_first_page_request(): bool
{
    return (string)($_GET['a'] ?? 'home') === 'home' && trim((string)($_GET['q'] ?? '')) === '' && max(1, (int)($_GET['p'] ?? 1)) === 1;
}
function sidebar_feature_links_html(array $ctx = []): string
{
    if (empty($ctx['is_home_first_page'])) return '';
    $links = hook('sidebar.feature_links', [], $ctx);
    if (!is_array($links) || !$links) return '';
    $html = '<div class="card sidebar-card quick-card"><div class="quick-wrap"><div class="quick-title">快捷功能</div><ul class="quick-links feature-links">';
    $count = 0;
    foreach ($links as $key => $link) {
        if (is_array($link)) {
            $text = trim((string)($link['text'] ?? $link['title'] ?? $link['label'] ?? ''));
            $url = trim((string)($link['url'] ?? $link['href'] ?? ''));
            $badge = trim((string)($link['badge'] ?? ''));
            $badge_dot = !empty($link['badge_dot']) || !empty($link['dot']);
            $target = !empty($link['target']) ? ' target="' . h((string)$link['target']) . '"' : '';
            $rel = !empty($link['rel']) ? ' rel="' . h((string)$link['rel']) . '"' : '';
        } elseif (is_string($key) && is_string($link)) {
            $text = trim($key);
            $url = trim($link);
            $badge = '';
            $badge_dot = false;
            $target = $rel = '';
        } else continue;
        if ($text === '' || $url === '') continue;
        $badge_html = !$badge_dot && $badge !== '' ? '<span class="notify-badge">' . h($badge) . '</span>' : '';
        $html .= '<li' . ($badge_dot ? ' class="notify-dot-link"' : '') . '><a href="' . h($url) . '"' . $target . $rel . '><span class="feature-link-text">' . h($text) . '</span>' . $badge_html . '</a></li>';
        $count++;
    }
    return $count > 0 ? $html . '</ul></div></div>' : '';
}
function mobile_menu_section_html(string $title, array $links): string
{
    $html = '<section class="mobile-menu-section"><h3>' . h($title) . '</h3><nav class="mobile-menu-links">';
    foreach ($links as $link) {
        $url = (string)($link['url'] ?? '');
        $text = trim((string)($link['text'] ?? ''));
        if ($url === '' || $text === '') continue;
        $html .= '<a class="mobile-menu-link" href="' . h($url) . '">' . h($text) . '</a>';
    }
    return $html . '</nav></section>';
}
function top_menu_links(bool $mobile, ?array $mine = null): array
{
    $raw_links = hook('top.menu_links', [], ['mobile' => $mobile, 'user' => $mine]);
    if (!is_array($raw_links)) return [];
    $links = [];
    foreach ($raw_links as $link) {
        if (!is_array($link)) continue;
        $text = trim((string)($link['text'] ?? $link['title'] ?? $link['label'] ?? ''));
        $url = trim((string)($link['url'] ?? $link['href'] ?? ''));
        if ($text !== '' && $url !== '') $links[] = ['text' => $text, 'url' => $url];
    }
    return $links;
}
function mobile_menu_html(?array $mine = null, ?array $forums = null): string
{
    $forums ??= array_values(array_filter(forums_cache(), fn($forum) => forum_group_allowed($forum, 'allow_view_groups')));
    $forum_links = [['text' => '全部版块', 'url' => route_url('home')]];
    foreach ($forums as $f) {
        $forum_links[] = ['text' => (string)$f['name'], 'url' => route_url('forum', ['id' => (int)$f['id']])];
    }
    $forum_links = array_merge($forum_links, top_menu_links(true, $mine));
    $my_links = [];
    if ($mine) {
        $uid = (int)$mine['id'];
        $my_links[] = ['text' => '我的主页', 'url' => route_url('user', ['id' => $uid])];
        $my_links[] = ['text' => '我的主题', 'url' => route_url('user', ['id' => $uid, 'tab' => 'topics'])];
        $my_links[] = ['text' => '我的回帖', 'url' => route_url('user', ['id' => $uid, 'tab' => 'replies'])];
        $my_links[] = ['text' => '我的通知', 'url' => route_url('user', ['id' => $uid, 'tab' => 'notifications'])];
        $my_links[] = ['text' => '个人设置', 'url' => route_url('profile')];
        if (can_access_admin()) $my_links[] = ['text' => '后台面板', 'url' => route_url('admin')];
        $extra_links = hook('user.menu_links', [], ['user' => $mine, 'self' => true, 'mobile' => true]);
        if (is_array($extra_links)) foreach ($extra_links as $link) if (is_array($link)) $my_links[] = $link;
    } else {
        $my_links[] = ['text' => '登录', 'url' => route_url('login')];
        if (setting('allow_register', '1') === '1') $my_links[] = ['text' => '注册', 'url' => route_url('register')];
    }
    $quick_links = [];
    $raw_links = is_home_first_page_request() ? hook('sidebar.feature_links', [], ['is_mobile_menu' => true, 'is_home_first_page' => true]) : [];
    if (is_array($raw_links)) {
        foreach ($raw_links as $key => $link) {
            if (is_array($link)) {
                $text = trim((string)($link['text'] ?? $link['title'] ?? $link['label'] ?? ''));
                $url = trim((string)($link['url'] ?? $link['href'] ?? ''));
            } elseif (is_string($key) && is_string($link)) {
                $text = trim($key);
                $url = trim($link);
            } else {
                continue;
            }
            if ($text !== '' && $url !== '') $quick_links[] = ['text' => $text, 'url' => $url];
        }
    }
    $quick_section = $quick_links ? mobile_menu_section_html('快捷功能', $quick_links) : '';
    return '<div class="mobile-menu-backdrop" id="mobile-menu" hidden><aside class="mobile-menu-drawer" id="mobile-menu-drawer" aria-label="移动端菜单"><div class="mobile-menu-head"><strong>菜单</strong><button type="button" class="mobile-menu-close" data-mobile-menu-close aria-label="关闭菜单">×</button></div><div class="mobile-menu-body">' . mobile_menu_section_html('版块列表', $forum_links) . mobile_menu_section_html('我的菜单', $my_links) . $quick_section . '</div></aside></div>';
}
function shell_html(string $main, string $sidebar, string $class = ''): string
{
    $mainpanel_extra = (string)hook('mainpanel_extra', '', ['main' => $main, 'class' => $class]);
    $layout_class = 'forum-layout' . ($sidebar !== '' ? ' forum-layout-has-sidebar' : '');
    return '<div class="home-shell' . ($class !== '' ? ' ' . h($class) : '') . '"><div class="' . $layout_class . '"><div class="forum-main"><div class="main-panel">' . $main . $mainpanel_extra . '</div></div>' . $sidebar . '</div></div>';
}
function tab_bar_html(array $items, string $active, string $class = ''): string
{
    $html = '<div class="tab-bar' . ($class !== '' ? ' ' . $class : '') . '">';
    foreach ($items as $key => $item) {
        $label = is_array($item) ? (string)($item['label'] ?? '') : (string)$item;
        $href = is_array($item) ? (string)($item['href'] ?? '#') : '#';
        $extra = is_array($item) ? (string)($item['class'] ?? '') : '';
        $html .= '<a class="tab' . ($active === $key ? ' active' : '') . ($extra !== '' ? ' ' . $extra : '') . '" href="' . h($href) . '">' . $label . '</a>';
    }
    return $html . '</div>';
}
function auth_tabs_html(string $active): string
{
    return tab_bar_html([
        'login' => ['label' => '登录', 'href' => route_url('login')],
        'register' => ['label' => '注册', 'href' => route_url('register')],
    ], $active, 'auth-tabs');
}
function sidebar_stack_html(array $parts, array $ctx = []): string
{
    $filtered = hook('sidebar.stack', $parts, $ctx);
    if (is_array($filtered)) $parts = $filtered;
    $entries = array_values(array_filter([sidebar_feature_links_html($ctx)], fn($part) => $part !== ''));
    if ($entries) array_splice($parts, 1, 0, $entries);
    $html = '<aside class="sidebar">';
    foreach ($parts as $part) if ($part !== '') $html .= $part;
    return $html . '</aside>';
}
function sidebar_user_card_html(?array $m = null, bool $reply_button = false, int $fid = 0): string
{
    $m = $m ?: me();
    if (!$m) return '<div class="card sidebar-card user-card"><div class="user-wrap"><div class="user-header"><div class="user-header-info"><div class="user-avatar-big visitor-avatar">P</div><div><div class="user-name">访客</div><div class="user-rank">请登录后发帖</div></div></div></div><div class="side-auth' . (setting('allow_register', '1') === '1' ? '' : ' single') . '"><a href="' . h(route_url('login')) . '">登录</a>' . (setting('allow_register', '1') === '1' ? '<a href="' . h(route_url('register')) . '">注册</a>' : '') . '</div></div></div>';
    $is_self = uid() && (int)$m['id'] === uid();
    $prefix = $is_self ? '我的' : 'TA的';
    $unread = $is_self ? (int)($m['unread_notifications'] ?? 0) : 0;
    $links = '<a href="' . h(route_url('user', ['id' => (int)$m['id'], 'tab' => 'topics'])) . '">' . svg_icon('topic') . $prefix . '主题</a><a href="' . h(route_url('user', ['id' => (int)$m['id'], 'tab' => 'replies'])) . '">' . svg_icon('reply') . $prefix . '回帖</a>';
    $extra_links = hook('user.menu_links', [], ['user' => $m, 'self' => $is_self, 'mobile' => false]);
    if (is_array($extra_links)) {
        foreach ($extra_links as $link) {
            if (!is_array($link)) continue;
            $text = trim((string)($link['text'] ?? ''));
            $url = trim((string)($link['url'] ?? ''));
            if ($text === '' || $url === '') continue;
            $icon = (string)($link['icon'] ?? '');
            $icon_html = (string)($link['icon_html'] ?? '');
            $target = !empty($link['target']) ? ' target="' . h((string)$link['target']) . '"' : '';
            $rel = !empty($link['rel']) ? ' rel="' . h((string)$link['rel']) . '"' : '';
            $links .= '<a href="' . h($url) . '"' . $target . $rel . '>' . ($icon_html !== '' ? $icon_html : ($icon !== '' ? svg_icon($icon) : '')) . h($text) . '</a>';
        }
    }
    if ($is_self) $links .= '<a href="' . h(route_url('user', ['id' => (int)$m['id'], 'tab' => 'notifications'])) . '">' . svg_icon('notify') . $prefix . '通知' . notification_badge_html($unread) . '</a><a href="' . h(route_url('profile')) . '">' . svg_icon('settings') . '个人设置</a>' . (can_access_admin() ? '<a href="' . h(route_url('admin')) . '">' . svg_icon('admin') . '后台面板</a>' : '');
    $user_url = route_url('user', ['id' => (int)$m['id']]);
    $rank = h($m['group_name'] ?? '用户') . ' · 积分 ' . (int)($m['points'] ?? 0);
    $state_tags = user_state_tag_html($m);
    $html = '<div class="card sidebar-card user-card"><div class="user-wrap"><div class="user-header"><div class="user-header-info"><a class="user-avatar-big" href="' . $user_url . '">' . avatar_tag((int)$m['id'], (string)$m['username'], (string)($m['avatar_style'] ?? ''), '', (string)($m['avatar_seed'] ?? '')) . '</a><div><a class="user-name" href="' . $user_url . '">' . h($m['username']) . '</a><div class="user-rank">' . $rank . $state_tags . '</div></div></div></div><div class="user-links">' . $links . '</div></div>';
    if (can_speak()) $html .= '<a class="btn-post' . ($is_self ? '' : ' notify-link') . '" href="' . h($reply_button ? '#reply' : ($is_self ? route_url('topic_edit', ['fid' => $fid ?: null]) : route_url('notify', ['id' => (int)$m['id']]))) . '"' . ($is_self || $reply_button ? '' : ' onclick="openNotify(this.href);return false"') . '>' . ($reply_button ? '回帖' : ($is_self ? '+ 发帖' : '私信TA')) . '</a>';
    return $html . '</div>';
}
function sidebar_stats_card_html(): string
{
    $stats = stats_cache();
    $html = '<div class="card sidebar-card stats-card"><div class="stats-wrap"><div class="stats-title">站点统计</div><div class="stats-sub">主题 ' . (int)$stats['topics'] . ' · 回复 ' . (int)$stats['replies'] . ' · 用户 ' . (int)$stats['users'] . '</div><div class="new-users-title">最新用户</div><div class="new-users">';
    foreach (($stats['latest_users'] ?? []) as $u) $html .= '<a class="nu-item" href="' . h(route_url('user', ['id' => (int)$u['id']])) . '"><div class="nu-avatar-circle">' . avatar_tag((int)$u['id'], (string)$u['username'], (string)($u['avatar_style'] ?? ''), '', (string)($u['avatar_seed'] ?? '')) . '</div><span class="nu-name">' . h($u['username']) . '</span></a>';
    return $html . '</div></div></div>';
}
function sidebar_bio_card_html(?array $user): string
{
    if (!$user || trim((string)($user['bio'] ?? '')) === '') return '';
    return '<div class="card sidebar-card bio-card"><div class="quick-wrap"><div class="quick-title">个人简介</div><div class="sidebar-bio">' . h($user['bio']) . '</div></div></div>';
}
function topic_user_group_html(array $row): string
{
    $gid = (int)($row['group_id'] ?? 0);
    $default_gid = (int)setting('default_group_id', '2');
    if ($gid <= 0 || $gid === $default_gid) return '';
    $g = group_by_id($gid);
    return $g ? '<span class="post-user-group">' . h($g['name']) . '</span>' : '';
}
function form_shell(string $body, ?array $m = null): string
{
    return shell_html($body, sidebar_stack_html([sidebar_user_card_html($m)]));
}
function stats_cache(): array
{
    if (is_array($GLOBALS['__home_stats_cache'] ?? null)) return $GLOBALS['__home_stats_cache'];
    $settings = settings_cache();
    $latest_users = json_decode((string)($settings['stats_latest_users'] ?? ''), true);
    if (!is_array($latest_users)) {
        $latest_users = q("SELECT id,username,avatar_style,avatar_seed FROM app_users ORDER BY id DESC LIMIT 8")->fetchAll();
        save_settings_values(['stats_latest_users' => json_encode($latest_users, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
    }
    return $GLOBALS['__home_stats_cache'] = [
        'topics' => (int)($settings['stats_topics'] ?? 0),
        'replies' => (int)($settings['stats_replies'] ?? 0),
        'users' => (int)($settings['stats_users'] ?? 0),
        'latest_users' => $latest_users,
    ];
}
function home_stats_record_insert(string $type, int $id): void
{
    $key = ['replies' => 'stats_replies', 'users' => 'stats_users'][$type] ?? '';
    if ($key === '') throw new InvalidArgumentException('无效的首页统计类型。');
    $values = [$key => (string)$id];
    if ($type === 'users') $values['stats_latest_users'] = json_encode(q("SELECT id,username,avatar_style,avatar_seed FROM app_users ORDER BY id DESC LIMIT 8")->fetchAll(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    save_settings_values($values);
    unset($GLOBALS['__home_stats_cache']);
}
function home_stats_refresh_topics(): void
{
    save_settings_values(['stats_topics' => (string)(int)val('SELECT COUNT(*) FROM app_topics')]);
    unset($GLOBALS['__home_stats_cache']);
}
function home_stats_refresh_replies(): void
{
    home_stats_record_insert('replies', (int)val('SELECT COALESCE(MAX(id),0) FROM app_replies'));
}
function now(): int
{
    return time();
}
function uid(): int
{
    if (array_key_exists('__request_uid', $GLOBALS)) return (int)$GLOBALS['__request_uid'];
    $user = me();
    return $user ? (int)$user['id'] : 0;
}
function is_super_user(): bool
{
    return uid() === 1;
}
function clear_auth_cookie(): void
{
    auth_cookie_clear();
    csrf_cookie_clear();
    $GLOBALS['__request_uid'] = 0;
    $GLOBALS['__me_cache'] = null;
}
function me(): ?array
{
    if (array_key_exists('__me_cache', $GLOBALS)) return is_array($GLOBALS['__me_cache']) ? $GLOBALS['__me_cache'] : null;
    $parts = auth_cookie_parts();
    if (!$parts || $parts['expire'] < time()) {
        if ($parts) auth_cookie_clear();
        return $GLOBALS['__me_cache'] = null;
    }
    $u = row('app_users', 'id', $parts['id']);
    $expected = $u ? hash_hmac('sha256', $parts['id'] . '|' . $parts['expire'], (string)$u['password']) : '';
    if (!$u || !hash_equals($expected, $parts['signature'])) {
        clear_auth_cookie();
        return null;
    }
    $g = group_by_id((int)$u['group_id']);
    if (!$g) {
        $fallback_group_id = (int)setting('default_group_id', '2');
        $g = group_by_id($fallback_group_id);
        if (!$g) {
            clear_auth_cookie();
            return null;
        }
        q('UPDATE app_users SET group_id=? WHERE id=? AND group_id=?', [$fallback_group_id, (int)$u['id'], (int)$u['group_id']]);
        $u['group_id'] = $fallback_group_id;
    }
    $GLOBALS['__request_uid'] = (int)$u['id'];
    return $GLOBALS['__me_cache'] = $u + ['group_name' => $g['name'], 'group_id' => (int)($u['group_id'] ?? 0), 'is_banned' => (int)($u['is_banned'] ?? 0), 'is_muted' => (int)($u['is_muted'] ?? 0), 'allow_manage' => (int)($g['allow_manage'] ?? 0), 'allow_admin' => (int)($g['allow_admin'] ?? 0)];
}
function can_manage(): bool
{
    if (is_super_user()) return true;
    $u = me();
    return $u && (int)($u['allow_manage'] ?? 0) === 1;
}
function can_access_admin(): bool
{
    if (is_super_user()) return true;
    $u = me();
    return $u && (int)($u['allow_admin'] ?? 0) === 1;
}
function is_muted(): bool
{
    if (is_super_user()) return false;
    $u = me();
    return $u && !can_access_admin() && (int)$u['is_muted'] === 1;
}
function can_speak(): bool
{
    if (!uid() || is_muted()) return false;
    return hook('user.can_speak', true, ['user' => me()]) === true;
}
function consume_auth_return_url(): string
{
    $url = trim((string)($_COOKIE['__auth_return_url'] ?? ''));
    app_cookie('__auth_return_url', '', time() - 3600);
    if ($url === '' || str_starts_with($url, '//') || str_contains($url, '\\')) return route_url('home');
    if (preg_match('/[\x00-\x1F\x7F]/', $url) || preg_match('/^[a-z][a-z0-9+.-]*:/i', $url)) return route_url('home');
    return $url;
}
function start_cookie_login(int $user_id): void
{
    $user = row('app_users', 'id', $user_id) ?: err('用户不存在');
    csrf_cookie_clear();
    auth_cookie_set($user_id, (string)$user['password']);
    $GLOBALS['__request_uid'] = $user_id;
    unset($GLOBALS['__me_cache']);
}
function complete_login(int $user_id): never
{
    start_cookie_login($user_id);
    go(consume_auth_return_url());
}
function need_login(): void
{
    if (!me()) go(route_url('login'));
}
function need_speak(): void
{
    need_login();
    $allowed = hook('user.can_speak', true, ['user' => me()]);
    if ($allowed !== true) err(is_string($allowed) ? $allowed : '禁止发言');
    if (is_muted()) err('禁止发言');
}
function need_admin(): void
{
    need_login();
    if (!can_access_admin()) err('无权限');
}
function need_manage(): void
{
    need_login();
    if (!can_manage()) err('无权限');
}
function need_site_access(): void
{
    if (!db_schema_ready()) err('请访问 index.php?a=install 进行安装', 200, 'simple', false, index_url(['a' => 'install']));
    if (!is_super_user() && me() && !can_access_admin() && (int)me()['is_banned'] === 1 && ($_GET['a'] ?? '') !== 'logout') err('当前用户禁止访问');
    $a = $_GET['a'] ?? 'home';
    limit_pagination_request_pages();
    if (setting('site_closed') === '1' && !can_access_admin()) {
        $core_allowed = in_array($a, ['login', 'logout', 'form_error', 'cron', 'robots.txt', 'favicon.ico', 'apple-touch-icon.png', 'apple-touch-icon-precomposed.png'], true);
        if (!$core_allowed && hook('site.closed_allow', false, ['action' => $a]) !== true) err('网站已关闭');
    }
}
function check(): void
{
    if (uid()) me();
    $is_post = is_post_request();
    $action = (string)($_GET['a'] ?? '');
    if ($is_post && hook('request.csrf_exempt', false, ['action' => $action]) === true) return;
    if ($is_post && !hash_equals(csrf_token(), (string)($_POST['_csrf'] ?? ''))) {
        err('请求已过期');
    }
}
function ajax_request(): bool
{
    return ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest';
}
function is_post_request(): bool
{
    return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
}
function json_response(array $data): never
{
    if (!isset($data['tip']) && !empty($GLOBALS['__point_change_tip'])) $data['tip'] = (string)$GLOBALS['__point_change_tip'];
    if (!isset($data['tip']) && !empty($GLOBALS['__ajax_tip'])) $data['tip'] = (string)$GLOBALS['__ajax_tip'];
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
    exit;
}
function set_flash(string $message): void
{
    $_COOKIE['__flash'] = $message;
    app_cookie('__flash', $message, time() + 30, true, false);
}
function err(string $message, int $status = 200, string $mode = 'auto', ?bool $log = null, string $url = ''): never
{
    $status = $status > 0 ? $status : 200;
    $is_not_found = $status === 404;
    if ($log === true) debug_log_write($message);
    if ($status !== 200) http_response_code($status);
    if ($mode === 'auto') {
        if (ajax_request()) $mode = 'ajax';
        elseif (!is_file(INSTALL_LOCK_FILE)) $mode = 'simple';
        elseif (!$is_not_found && is_post_request()) $mode = 'form';
        else $mode = 'page';
    }
    if ($mode === 'ajax') {
        json_response(['ok' => 0, 'message' => $message]);
    }
    if ($mode === 'form') {
        $value = base64_encode(json_encode([
            'message' => $message,
            'created_at' => time(),
        ], JSON_UNESCAPED_UNICODE));
        app_cookie('__form_error', $value, time() + 30);
        go(route_url('form_error'));
    }
    if ($mode === 'simple') {
        header('Content-Type: text/html; charset=utf-8');
        $content = $url !== '' ? '<a href="' . h($url) . '">' . h($message) . '</a>' : h($message);
        echo '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>错误</title><style>body{margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;background:#f5f7fb;color:#222;font:14px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}.box{max-width:420px;padding:28px 24px;background:#fff;border:1px solid #e5e7eb;border-radius:10px;box-shadow:0 12px 30px rgba(15,23,42,.06)}.box a{color:#2563eb;text-decoration:none}.box a:hover{text-decoration:underline}</style></head><body><div class="box">' . $content . '</div></body></html>';
        exit;
    }
    error_page($is_not_found ? '404' : '消息', $message, $status);
}
function go(string $u): never
{
    if (ajax_request()) json_response(['ok' => 1, 'redirect' => $u]);
    if (ob_get_level() > 0) ob_end_clean();
    header("Location: $u");
    exit;
}
function error_page(string $title, string $message, int $status = 200): never
{
    if ($status > 0) http_response_code($status);
    $message = trim($message);
    $body = '<div class="form-panel form-error-panel"><h2>' . h($title) . '</h2><p>' . h($message !== '' ? $message : $title) . '</p></div>';
    page($title, shell_html($body, sidebar_stack_html([sidebar_user_card_html()])));
    exit;
}
function database_error(Throwable $e): bool
{
    do {
        if ($e instanceof PDOException) return true;
        $message = strtolower($e->getMessage());
        if (str_contains($message, 'sqlite') || str_contains($message, 'sqlstate') || str_contains($message, 'database')) return true;
        $e = $e->getPrevious();
    } while ($e);
    return false;
}
function database_error_message(): string
{
    return '数据库出了点小问题';
}
function cut(string $v, int $max): string
{
    static $has_mb = null;
    $has_mb ??= function_exists('mb_substr');
    return $has_mb ? mb_substr($v, 0, $max, 'UTF-8') : substr($v, 0, $max);
}
function human_time(int $ts): string
{
    $diff = time() - $ts;
    if ($diff < 60) return '刚刚';
    if ($diff < 3600) return floor($diff / 60) . '分钟前';
    if ($diff < 86400) return floor($diff / 3600) . '小时前';
    if ($diff < 172800) return '昨天';
    if ($diff < 604800) return floor($diff / 86400) . '天前';
    return date('Y-m-d', $ts);
}
function max_pagination_pages(): int
{
    return min(1000, max(1, (int)setting('max_pagination_pages', '50')));
}
function limit_pagination_request_pages(): void
{
    if (is_post_request() || (string)($_GET['a'] ?? 'home') === 'topic') return;
    $max = max_pagination_pages();
    foreach ($_GET as $key => $value) {
        if ($key !== 'p' && !str_ends_with((string)$key, '_p')) continue;
        $_GET[$key] = (string)min($max, max(1, (int)$value));
    }
}
function paginate(int $total, int $page, int $size, string $url, bool $limited = true): string
{
    $pages = max(1, (int)ceil($total / $size));
    if ($limited) $pages = min($pages, max_pagination_pages());
    if ($pages <= 1) return '';
    $page = max(1, min($page, $pages));
    $page_url = fn(int $n): string => append_url_query($url, ['p' => $n]);
    $h = '<div class="pagination"><ul>';
    if ($page > 1) $h .= '<li><a href="' . h($page_url($page - 1)) . '">上一页</a></li>';
    $start = max(1, $page - 2);
    $end = min($pages, $page + 2);
    if ($start > 1) {
        $h .= '<li><a href="' . h($page_url(1)) . '">1</a></li>';
        if ($start > 2) $h .= '<li><span class="ellipsis">...</span></li>';
    }
    for ($i = $start; $i <= $end; $i++) {
        $h .= '<li' . ($i === $page ? ' class="active"' : '') . '><a href="' . h($page_url($i)) . '">' . $i . '</a></li>';
    }
    if ($end < $pages) {
        if ($end < $pages - 1) $h .= '<li><span class="ellipsis">...</span></li>';
        $h .= '<li><a href="' . h($page_url($pages)) . '">' . $pages . '</a></li>';
    }
    if ($page < $pages) $h .= '<li><a href="' . h($page_url($page + 1)) . '">下一页</a></li>';
    $h .= '</ul></div>';
    return $h;
}
function simple_paginate(bool $has_prev, bool $has_next, int $page, string $url, bool $limited = true): string
{
    if ($limited && $page >= max_pagination_pages()) $has_next = false;
    if (!$has_prev && !$has_next) return '';
    $page_url = fn(int $n): string => append_url_query($url, ['p' => $n]);
    $h = '<div class="pagination"><ul>';
    if ($has_prev) $h .= '<li><a href="' . h($page_url(max(1, $page - 1))) . '">上一页</a></li>';
    $h .= '<li class="active"><a href="' . h($page_url($page)) . '">' . $page . '</a></li>';
    if ($has_next) $h .= '<li><a href="' . h($page_url($page + 1)) . '">下一页</a></li>';
    return $h . '</ul></div>';
}
function topic_page_links(int $topic_id, int $reply_count): string
{
    $size = max(1, (int)setting('replies_per_page', '50'));
    $pages = (int)ceil($reply_count / $size);
    if ($pages <= 1) return '';
    $nums = [];
    foreach ([2, 3, $pages - 2, $pages - 1, $pages] as $n) if ($n >= 2 && $n <= $pages) $nums[$n] = true;
    $nums = array_keys($nums);
    sort($nums);
    $h = '<span class="topic-pages">' . svg_icon('pages');
    $prev = 1;
    foreach ($nums as $i) {
        if ($i - $prev > 1) $h .= '<span class="topic-pages-sep">…</span>';
        $h .= '<a href="' . h(route_url('topic', ['id' => $topic_id, 'p' => $i])) . '">' . $i . '</a>';
        $prev = $i;
    }
    return $h . '</span>';
}
function post(string $k, int $max = 0): string
{
    $v = trim((string)($_POST[$k] ?? ''));
    return $max ? cut($v, $max) : $v;
}
function id(string $k = 'id'): int
{
    return max(0, (int)($_GET[$k] ?? $_POST[$k] ?? 0));
}
function form_token(): string
{
    return '<input type="hidden" name="_csrf" value="' . h(csrf_token()) . '">';
}
function hidden_inputs(array $fields): string
{
    $html = '';
    foreach ($fields as $name => $value) {
        if ($value === null) continue;
        $html .= '<input type="hidden" name="' . h((string)$name) . '" value="' . h((string)$value) . '">';
    }
    return $html;
}
function post_action_form(string $action, string $label, array $fields = [], string $class = '', string $confirm = ''): string
{
    $confirm_attr = $confirm !== '' ? ' data-confirm="' . h($confirm) . '"' : '';
    return '<form class="post-action-form" method="post" action="' . h($action) . '"' . $confirm_attr . '>' . form_token() . hidden_inputs($fields) . '<button type="submit"' . ($class !== '' ? ' class="' . h($class) . '"' : '') . '>' . h($label) . '</button></form>';
}
function require_post(): void
{
    if (!is_post_request()) err('请求方式错误');
}
function svg_icon(string $name): string
{
    static $icons = [
        'user' => '<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M4 21c1.8-4 4.5-6 8-6s6.2 2 8 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
        'reply' => '<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>',
        'notify' => '<path d="M12 18.5a2.5 2.5 0 0 0 2.4-1.8H9.6a2.5 2.5 0 0 0 2.4 1.8Zm7-4.5-1.6-1.9V10a5.4 5.4 0 0 0-4.4-5.3V4a1 1 0 1 0-2 0v.7A5.4 5.4 0 0 0 6.6 10v2.1L5 14v1h14z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>',
        'forum' => '<path d="M4 5h16v14H4z" stroke="currentColor" stroke-width="2"/><path d="M8 9h8M8 13h5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
        'topic' => '<path d="M5 4h14v16H5z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
        'view' => '<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>',
        'settings' => '<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
        'admin' => '<path d="M12 3 4 6v6c0 5 3.4 7.8 8 9 4.6-1.2 8-4 8-9V6l-8-3Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M9 12l2 2 4-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
        'pages' => '<path d="M8 4h9a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M9.5 9h6M9.5 12.5h6M9.5 16h3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
    ];
    return isset($icons[$name]) ? '<svg class="meta-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">' . $icons[$name] . '</svg>' : '';
}
function avatar_styles(): array
{
    static $styles;
    if ($styles === null) {
        $names = explode(' ', 'dylan big-ears big-ears-neutral big-smile disco lorelei lorelei-neutral pixel-art pixel-art-neutral adventurer adventurer-neutral avataaars avataaars-neutral bottts bottts-neutral croodles croodles-neutral fun-emoji glass glyphs icons identicon initial-face initials micah miniavs notionists notionists-neutral open-peeps personas rings shape-grid shapes stripes thumbs toon-head triangles');
        $styles = array_combine($names, array_map(fn(string $name): string => ucwords(str_replace('-', ' ', $name)), $names));
    }
    return $styles;
}
function avatar_style(string $style): string
{
    if ($style === '') return '';
    $styles = avatar_styles();
    return isset($styles[$style]) ? $style : 'dylan';
}
function avatar_seed_count(string $style): int
{
    return 48;
}
function decimal_mod(string $number, int $divisor): int
{
    $mod = 0;
    foreach (str_split($number) as $digit) $mod = ($mod * 10 + (int)$digit) % $divisor;
    return $mod;
}
function avatar_seed(string $style, string $seed, int $uid = 0): string
{
    $count = max(1, avatar_seed_count($style));
    $seed = trim($seed);
    if ($seed === '') $seed = (string)$uid;
    if (ctype_digit($seed)) {
        $mod = decimal_mod($seed, $count);
        return (string)($mod === 0 ? $count : $mod);
    }
    $hash = (string)sprintf('%u', crc32($seed));
    $mod = decimal_mod($hash, $count);
    return (string)($mod === 0 ? $count : $mod);
}
function avatar_remote_url(string $style, string $seed): string
{
    return 'https://api.dicebear.com/10.x/' . rawurlencode($style) . '/svg?seed=' . rawurlencode($seed);
}
function avatar_tag(int $uid, string $name, string $style = '', string $class = '', string $seed = ''): string
{
    $classes = trim('avatar-img ' . $class);
    $style = avatar_style($style) ?: 'dylan';
    $seed = avatar_seed($style, $seed, $uid);
    $src = (string)hook('avatar.url', avatar_remote_url($style, $seed), ['style' => $style, 'seed' => $seed, 'uid' => $uid]);
    return '<img class="' . h($classes) . '" src="' . h($src) . '" alt="' . h($name) . '" loading="lazy">';
}
function avatar_link_tag(int $uid, string $name, string $style = '', string $class = '', string $seed = ''): string
{
    $avatar = avatar_tag($uid, $name, $style, $class, $seed);
    if ($uid < 1) return $avatar;
    return '<a class="avatar-profile-link" href="' . h(route_url('user', ['id' => $uid])) . '" aria-label="查看 ' . h($name) . ' 的个人主页">' . $avatar . '</a>';
}
function app_url(string $path = ''): string
{
    $path = ltrim($path, '/');
    $dir = rtrim(str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '/index.php'))), '/');
    $base = ($dir === '' || $dir === '.') ? '' : $dir;
    if ($path === '') return $base === '' ? '/' : $base . '/';
    return $base . '/' . $path;
}
function append_url_query(string $url, array $params): string
{
    $query = [];
    foreach ($params as $key => $value) {
        if ($value === null || $value === '') continue;
        $query[$key] = (string)$value;
    }
    if (!$query) return $url;
    return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
function index_url(array $params = []): string
{
    return append_url_query(app_url('index.php'), $params);
}
function admin_url(array $params = []): string
{
    return route_url('admin', $params);
}
function route_url(string $a = 'home', array $params = []): string
{
    if (setting('pretty_url', '0') !== '1') return $a === 'home' ? index_url($params) : index_url(['a' => $a] + $params);
    if ($a === 'home') return $params ? index_url($params) : app_url();
    $params = $a === 'home' ? $params : ['a' => $a] + $params;
    $segments = [];
    if (isset($params['a']) && $params['a'] !== '') {
        $segments[] = rawurlencode((string)$params['a']);
        unset($params['a']);
    }
    if (isset($params['id']) && ctype_digit((string)$params['id'])) {
        $segments[] = rawurlencode((string)$params['id']);
        unset($params['id']);
    }
    return append_url_query(app_url(implode('/', $segments)), $params);
}
function asset_url(string $file): string
{
    return app_url($file);
}
function markdown_link_text(string $text): string
{
    return str_replace([']', '['], ['\]', '\['], $text);
}
function upload_hash_dir(string $hash): string
{
    return substr($hash, 0, 2);
}
function parse_path_route(): void
{
    $path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?? '');
    $script = str_replace('\\', '/', (string)($_SERVER['SCRIPT_NAME'] ?? '/index.php'));
    $base = rtrim(str_replace('\\', '/', dirname($script)), '/');
    if ($base !== '' && $base !== '.' && str_starts_with($path, $base . '/')) $path = substr($path, strlen($base));
    $path = trim($path, '/');
    if ($path === '' || $path === basename($script)) return;
    $segments = array_values(array_filter(explode('/', $path), 'strlen'));
    if (isset($segments[0]) && $segments[0] !== 'a' && !array_key_exists('a', $_GET)) $_GET['a'] = rawurldecode($segments[0]);
    if (isset($segments[1]) && ctype_digit($segments[1]) && !array_key_exists('id', $_GET)) $_GET['id'] = rawurldecode($segments[1]);
}
function content_html_token(array &$tokens, string $html): string
{
    $key = "\x1B" . count($tokens) . "\x1B";
    $tokens[$key] = $html;
    return $key;
}
function content_special_links_html(string $escaped_text, int $topic_id = 0): string
{
    if (!str_contains($escaped_text, '@')) return $escaped_text;
    $tokens = [];
    $escaped_text = preg_replace_callback('/@([^\s@#<]{1,32})\s+#(\d+)/u', function ($m) use (&$tokens, $topic_id) {
        if ($topic_id <= 0) return $m[0];
        $url = route_url('topic', ['id' => $topic_id, 'floor' => (int)$m[2]]);
        return content_html_token($tokens, '<a href="' . h($url) . '" target="_blank" rel="noopener">@' . $m[1] . ' #' . (int)$m[2] . '</a>');
    }, $escaped_text) ?? $escaped_text;
    $escaped_text = preg_replace_callback('/(?<![\p{L}\p{N}._%+\-])@([\p{L}\p{N}_-]+(?:\.[\p{L}\p{N}_-]+)*)/u', function ($m) {
        $username = html_entity_decode((string)$m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
        return '<a href="' . h(route_url('user', ['username' => $username])) . '">@' . $m[1] . '</a>';
    }, $escaped_text) ?? $escaped_text;
    return strtr($escaped_text, $tokens);
}
function markdown_html(string $text, int $quote_depth = 0, int $topic_id = 0): string
{
    $text = (string)hook('markdown.before', $text);
    $text = str_replace(["\r\n", "\r"], "\n", trim($text));
    if ($text === '') return '';
    $rendered = hook('markdown.render', null, ['text' => $text, 'quote_depth' => $quote_depth, 'topic_id' => $topic_id]);
    if (is_string($rendered)) return (string)hook('markdown.after', $rendered, ['text' => $text]);
    $html = '<p>' . str_replace("\n", '<br>', content_special_links_html(h($text), $topic_id)) . '</p>';
    return (string)hook('markdown.after', $html, ['text' => $text]);
}
function avatar_picker_html(array $u): string
{
    $uid = (int)$u['id'];
    $style = avatar_style((string)($u['avatar_style'] ?? ''));
    $seed = (string)($u['avatar_seed'] ?? '');
    if ($seed !== '') $seed = avatar_seed($style ?: 'dylan', $seed, $uid);
    $name = (string)($u['username'] ?? '');
    $picker = hook('avatar.picker', ['style' => $style, 'seed' => $seed, 'styles' => avatar_styles(), 'local_only' => false, 'base_url' => ''], ['user' => $u]);
    if (!is_array($picker)) $picker = [];
    $styles = is_array($picker['styles'] ?? null) ? array_intersect_key(avatar_styles(), $picker['styles']) : avatar_styles();
    if (!$styles) $styles = avatar_styles();
    $style = avatar_style((string)($picker['style'] ?? $style));
    $seed = (string)($picker['seed'] ?? $seed);
    $local_only = !empty($picker['local_only']);
    $base_url = (string)($picker['base_url'] ?? '');
    $seeds = array_map('strval', range(1, avatar_seed_count($style ?: 'dylan')));
    $html = '<div class="grid avatar-field"><div class="avatar-picker profile-disclosure" data-profile-disclosure data-seed="' . $uid . '" data-avatar-base="' . h($base_url) . '" data-avatar-local-only="' . ($local_only ? '1' : '0') . '"><div class="profile-disclosure-summary"><div class="profile-disclosure-main"><span class="profile-avatar-summary">' . avatar_tag($uid, $name, $style, '', $seed) . '</span><span class="profile-disclosure-heading"><span>头像</span><small>当前头像</small></span></div><button class="profile-edit-action" type="button" data-profile-toggle aria-expanded="false">修改</button></div><div class="profile-disclosure-detail is-hidden" data-profile-edit><div class="avatar-picker-head"><div class="avatar-picker-preview">' . avatar_tag($uid, $name, $style, '', $seed) . '</div><select name="avatar_style">';
    if (!$local_only) $html .= '<option value=""' . ($style === '' ? ' selected' : '') . '>默认 Dylan</option>';
    foreach ($styles as $k => $v) $html .= '<option value="' . h($k) . '"' . ($k === $style ? ' selected' : '') . '>' . h($v) . '</option>';
    $html .= '</select></div><input type="hidden" name="avatar_seed" value="' . h($seed) . '"><div class="avatar-options">';
    if (!$local_only) $html .= '<button class="avatar-option' . ($seed === '' ? ' active' : '') . '" type="button" data-seed="">' . avatar_tag($uid, $name, $style, '', '') . '</button>';
    foreach ($seeds as $s) $html .= '<button class="avatar-option' . ($s === $seed ? ' active' : '') . '" type="button" data-seed="' . h($s) . '">' . avatar_tag($uid, $name, $style, '', $s) . '</button>';
    return $html . '</div></div></div></div>';
}
function topic_post_row(array $row, string $body, int $time, string $ops = '', string $title = '', string $stats = '', bool $highlight = false, array $ctx = []): string
{
    $is_reply = isset($row['topic_id']);
    $topic_id = $is_reply ? (int)$row['topic_id'] : (int)($row['id'] ?? 0);
    $floor = $is_reply ? (int)($ctx['reply_position'] ?? 0) : 0;
    $filtered = hook($is_reply ? 'reply.before_render' : 'topic.before_render', ['row' => $row, 'body' => $body], ['time' => $time]);
    if (is_array($filtered)) {
        if (isset($filtered['row']) && is_array($filtered['row'])) $row = $filtered['row'];
        if (isset($filtered['body'])) $body = (string)$filtered['body'];
    }
    $has_title = $title !== '';
    $title_html = $has_title ? '<div class="post-topic-title"><h1 class="post-content-title">' . h($title) . '</h1>' . $stats . '</div>' : '';
    $avatar = avatar_link_tag((int)$row['user_id'], (string)$row['username'], (string)($row['avatar_style'] ?? ''), '', (string)($row['avatar_seed'] ?? ''));
    $floor_attr = $floor > 0 ? ' data-floor="' . $floor . '"' : '';
    $floor_html = $floor > 0 ? '<a class="post-floor" href="' . h(route_url('topic', ['id' => $topic_id, 'floor' => $floor])) . '">#' . $floor . '</a>' : '';
    $ops_html = $ops !== '' || $floor_html !== '' ? '<div class="post-ops">' . $ops . $floor_html . '</div>' : '';
    $html = '<li class="post-item post-entry' . ($has_title ? ' has-title' : '') . ($highlight ? ' post-highlight' : '') . '" id="post-' . (int)($row['id'] ?? 0) . '"' . $floor_attr . '>' . $title_html . '<div class="post-avatar">' . $avatar . '</div><div class="post-body"><div class="post-head' . ($floor > 0 ? ' has-floor' : '') . '"><a class="post-title post-author" href="' . h(route_url('user', ['id' => (int)$row['user_id']])) . '">' . h($row['username']) . '</a>' . topic_user_group_html($row) . user_state_tag_html($row) . $ops_html . '</div><div class="post-meta"><span>' . human_time($time) . '</span></div></div><div class="post-content">' . markdown_html($body, 0, $topic_id) . '</div></li>';
    $html = (string)hook($is_reply ? 'reply.after_render' : 'topic.after_render', $html, ['row' => $row, 'body' => $body] + $ctx);
    if ($is_reply) return $html;
    $content_after = (string)hook('topic.content_after', '', ['row' => $row, 'body' => $body, 'topic_id' => $topic_id] + $ctx);
    if ($content_after === '') return $html;
    $end = strrpos($html, '</div></li>');
    return $end === false ? $html : substr($html, 0, $end) . $content_after . substr($html, $end);
}
function quote_reply_action(array $row, int $floor = 0): string
{
    $floor_attr = $floor > 0 ? ' data-floor="' . $floor . '"' : '';
    return '<a class="icon-action icon-quote quote-reply" href="#reply" data-username="' . h((string)$row['username']) . '"' . $floor_attr . ' title="引用回复"><span>引用回复</span></a>';
}
function topic_list_select_columns(): string
{
    static $cached = null;
    if ($cached !== null) return $cached;
    $columns = 'id,title,highlight_style,created_at,reply_count,last_reply_at,last_reply_user_id,forum_id,user_id';
    $allowed = ['id', 'forum_id', 'user_id', 'title', 'body', 'highlight_style', 'reply_order', 'reply_count', 'view_count', 'last_reply_at', 'last_reply_user_id', 'created_at'];
    $extra = hook('topic.list_columns', [], ['columns' => explode(',', $columns)]);
    if (!is_array($extra)) return $cached = $columns;
    $extra = array_values(array_intersect($allowed, array_filter($extra, 'is_string')));
    return $cached = implode(',', array_values(array_unique(array_merge(explode(',', $columns), $extra))));
}
function topic_fts_query(string $query, string $field = ''): string
{
    $query = trim($query);
    if ($query === '') $query = '__nomatch__';
    $quoted = '"' . str_replace('"', '""', $query) . '"';
    $field = in_array($field, ['title', 'body'], true) ? $field : '';
    return $field !== '' ? $field . ':' . $quoted : $quoted;
}
function sqlite_fts_uses_trigram(): bool
{
    static $enabled;
    if ($enabled !== null) return $enabled;
    if (db_driver() !== 'sqlite') return $enabled = false;
    $sql = (string)val("SELECT sql FROM sqlite_master WHERE type='table' AND name='app_topics_fts'");
    return $enabled = preg_match('/tokenize\s*=\s*[\'\"]trigram[\'\"]/i', $sql) === 1;
}
function search_min_chars(): int
{
    return min(20, max(1, (int)setting('search_min_chars', '2')));
}
function search_char_count(string $query): int
{
    $count = preg_match_all('/./us', trim($query));
    return $count === false ? 0 : $count;
}
function sqlite_search_uses_fts(string $query): bool
{
    return sqlite_fts_uses_trigram() && search_char_count($query) >= 3;
}
function require_search_min_chars(string $query): void
{
    $query = trim($query);
    if ($query === '') return;
    $minimum = search_min_chars();
    if (search_char_count($query) < $minimum) err('请至少输入' . $minimum . '个字符再搜索');
}
function topic_search_field(string $field): string
{
    return in_array($field, ['title', 'body', 'reply'], true) ? $field : 'title';
}
function search_like_pattern(string $query): string
{
    return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($query)) . '%';
}
function mysql_search_index_definitions(): array
{
    return [
        'idx_topics_search_title' => ['app_topics', 'mysql_search_index_topics_title'],
        'idx_topics_search_body' => ['app_topics', 'mysql_search_index_topics_body'],
        'idx_replies_search_body' => ['app_replies', 'mysql_search_index_replies_body'],
    ];
}
function mysql_search_index_settings(PDO $db, string $driver): array
{
    $settings = [];
    foreach (mysql_search_index_definitions() as $index => [$table, $setting]) {
        $settings[$setting] = $driver === 'mysql' && app_db_index_exists($db, $driver, $index, $table) ? '1' : '0';
    }
    return $settings;
}
function mysql_search_index_available(string $index): bool
{
    $definition = mysql_search_index_definitions()[$index] ?? null;
    return db_driver() === 'mysql' && $definition !== null && setting($definition[1], '0') === '1';
}
function content_search_condition(string $query, string $field = 'title'): array
{
    $field = in_array($field, ['title', 'body', 'reply'], true) ? $field : 'title';
    $reply = $field === 'reply';
    $column = $field === 'title' ? 'title' : 'body';
    $mysql_index = $reply ? 'idx_replies_search_body' : 'idx_topics_search_' . $field;
    if (mysql_search_index_available($mysql_index)) {
        $value = '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], trim($query)) . '"';
        return ['MATCH(' . $column . ') AGAINST(? IN BOOLEAN MODE)', [$value]];
    }
    if (db_driver() === 'pgsql') {
        return [$column . " ILIKE ? ESCAPE '!'", [search_like_pattern($query)]];
    }
    if (!sqlite_search_uses_fts($query)) return [$column . " LIKE ? ESCAPE '!'", [search_like_pattern($query)]];
    $fts_table = $reply ? 'app_replies_fts' : 'app_topics_fts';
    return [
        "id IN (SELECT rowid FROM $fts_table WHERE $fts_table MATCH ?)",
        [topic_fts_query($query, $reply ? '' : $field)],
    ];
}
function topic_fts_sync(int $id, string $title, string $body): void
{
    if (db_driver() !== 'sqlite' || !sqlite_fts_uses_trigram()) return;
    q("DELETE FROM app_topics_fts WHERE rowid=?", [$id]);
    q("INSERT INTO app_topics_fts(rowid,title,body) VALUES(?,?,?)", [$id, $title, $body]);
}
function topic_fts_delete(int $id): void
{
    if (db_driver() !== 'sqlite' || !sqlite_fts_uses_trigram()) return;
    q("DELETE FROM app_topics_fts WHERE rowid=?", [$id]);
}
function reply_fts_sync(int $id, string $body): void
{
    if (db_driver() !== 'sqlite' || !sqlite_fts_uses_trigram()) return;
    q("DELETE FROM app_replies_fts WHERE rowid=?", [$id]);
    q("INSERT INTO app_replies_fts(rowid,body) VALUES(?,?)", [$id, $body]);
}
function reply_fts_delete(int $id): void
{
    if (db_driver() !== 'sqlite' || !sqlite_fts_uses_trigram()) return;
    q("DELETE FROM app_replies_fts WHERE rowid=?", [$id]);
}
function topic_list_rows_for_replies(array $reply_rows): array
{
    if (!$reply_rows) return [];
    $topics = rows_by_ids('app_topics', array_column($reply_rows, 'topic_id'), topic_list_select_columns());
    $rows = [];
    foreach ($reply_rows as $reply) {
        $topic_id = (int)$reply['topic_id'];
        $topic = $topics[$topic_id] ?? [
            'id' => $topic_id,
            'title' => '已删除',
            'reply_only' => 1,
            'forum_id' => 0,
            'user_id' => (int)$reply['user_id'],
            'created_at' => (int)$reply['created_at'],
            'last_reply_at' => (int)$reply['created_at'],
            'reply_count' => 0,
        ];
        $rows[] = $topic + [
            'my_reply_at' => (int)$reply['created_at'],
            'my_reply_id' => (int)$reply['id'],
            'my_reply_excerpt' => content_excerpt(content_preview_source_text((string)$reply['body']), 180),
        ];
    }
    return attach_topic_list_users($rows);
}
function topic_list_row(array $t, string $sort): string
{
    $filtered = hook('topic.before_render', ['row' => $t], ['list' => true, 'sort' => $sort]);
    if (is_array($filtered) && isset($filtered['row']) && is_array($filtered['row'])) $t = $filtered['row'];
    $time = (int)($t['time'] ?? ($sort === 'post' ? $t['created_at'] : ($t['last_reply_at'] ?: $t['created_at'])));
    if (!empty($t['reply_only'])) {
        $reply_excerpt = trim((string)($t['my_reply_excerpt'] ?? ''));
        $reply_excerpt_html = $reply_excerpt !== '' ? '<div class="profile-reply-excerpt">' . h($reply_excerpt) . '</div>' : '';
        $user_link = '<a href="' . h(route_url('user', ['id' => (int)$t['user_id']])) . '">' . svg_icon('user') . h($t['username']) . '</a>';
        $html = '<li class="post-item"><div class="post-avatar">' . avatar_link_tag((int)$t['user_id'], (string)$t['username'], (string)($t['avatar_style'] ?? ''), '', (string)($t['avatar_seed'] ?? '')) . '</div><div class="post-body">' . $reply_excerpt_html . '<div class="post-meta"><span>' . $user_link . '</span><span>' . human_time($time) . '</span></div></div></li>';
        return (string)hook('topic.after_render', $html, ['row' => $t, 'list' => true, 'sort' => $sort]);
    }
    $forum = $t['forum'] ?? ['id' => (int)$t['forum_id'], 'name' => ''];
    $has_forum = (int)($forum['id'] ?? 0) > 0 && trim((string)($forum['name'] ?? '')) !== '';
    $user_link = '<a href="' . h(route_url('user', ['id' => (int)$t['user_id']])) . '">' . svg_icon('user') . h($t['username']) . '</a>';
    $forum_link = $has_forum ? '<a href="' . h(route_url('forum', ['id' => (int)$forum['id']])) . '">' . h($forum['name']) . '</a>' : '';
    $last_reply_username = (string)($t['last_reply_username'] ?? '');
    $last_reply_user = $last_reply_username !== '' ? '<span>' . svg_icon('user') . h($last_reply_username) . '</span>' : '';
    $time_meta = '<span>' . human_time($time) . '</span>';
    $forum_meta = $has_forum ? '<span class="post-forum-meta">' . svg_icon('forum') . $forum_link . '</span>' : '';
    $meta = '<span>' . $user_link . '</span>' . ($sort === 'post' ? $time_meta : '') . $forum_meta . '<span>' . svg_icon('reply') . (int)$t['reply_count'] . '</span>' . $last_reply_user . ($sort === 'post' ? '' : $time_meta);
    $pages = topic_page_links((int)$t['id'], (int)$t['reply_count']);
    $reply_id = (int)($t['my_reply_id'] ?? 0);
    $topic_url = route_url('topic', ['id' => (int)$t['id'], 'replyid' => $reply_id > 0 ? $reply_id : null]);
    $reply_excerpt = trim((string)($t['my_reply_excerpt'] ?? ''));
    $reply_excerpt_html = $reply_excerpt !== '' ? '<div class="profile-reply-excerpt">' . h($reply_excerpt) . '</div>' : '';
    $badges = ((int)($t['is_pinned'] ?? 0) ? '<span class="topic-badge pinned">置顶</span>' : '');
    $style = (string)($t['highlight_style'] ?? '') !== '' ? ' style="' . h((string)$t['highlight_style']) . '"' : '';
    $title_suffix = (string)hook('topic.title_suffix', '', ['row' => $t, 'list' => true, 'sort' => $sort]);
    $forum_badge = $has_forum ? '<a class="post-tag post-forum-badge" href="' . h(route_url('forum', ['id' => (int)$forum['id']])) . '">' . h($forum['name']) . '</a>' : '';
    $html = '<li class="post-item' . ((int)($t['is_pinned'] ?? 0) ? ' topic-pinned' : '') . '"><div class="post-avatar">' . avatar_link_tag((int)$t['user_id'], (string)$t['username'], (string)($t['avatar_style'] ?? ''), '', (string)($t['avatar_seed'] ?? '')) . '</div><div class="post-body"><div class="post-title-row">' . $badges . '<a class="post-title" href="' . h($topic_url) . '"' . $style . '>' . h($t['title']) . '</a>' . $title_suffix . $pages . '</div>' . $reply_excerpt_html . '<div class="post-meta">' . $meta . '</div></div>' . $forum_badge . '</li>';
    return (string)hook('topic.after_render', $html, ['row' => $t, 'list' => true, 'sort' => $sort]);
}
function topic_stats_html(int $view_count, int $reply_count): string
{
    $stats = '';
    if ($view_count > 0) $stats .= '<span>' . svg_icon('view') . $view_count . '</span>';
    if ($reply_count > 0) $stats .= '<span>' . svg_icon('reply') . $reply_count . '</span>';
    return $stats ? '<div class="post-content-stats">' . $stats . '</div>' : '';
}
function page_head_html(string $page_title, string $meta, string $head_extra = ''): string
{
    return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' . $meta . '<title>' . h($page_title) . '</title><link rel="icon" type="image/svg+xml" href="' . h(asset_url('app/assets/index.svg')) . '"><link rel="stylesheet" href="' . h(app_url('app/assets/index.css')) . '?v=' . h(APP_VERSION) . '">' . plugin_asset_tag('css') . $head_extra . '</head><body>';
}
function page_nav_html(string $site_name): string
{
    $q = trim((string)($_GET['q'] ?? ''));
    $search_field = topic_search_field((string)($_GET['field'] ?? 'title'));
    $active_forum = ($_GET['a'] ?? '') === 'forum' ? id() : 0;
    $mine = me();
    $mine_unread = $mine ? (int)($mine['unread_notifications'] ?? 0) : 0;
    $mine_link = $mine ? route_url('user', ['id' => (int)$mine['id'], 'tab' => $mine_unread > 0 ? 'notifications' : null]) : route_url('login');
    $mine_label = $mine ? '我的' . notification_badge_html($mine_unread) : '登录';
    $forums = array_values(array_filter(forums_cache(), fn($f) => forum_group_allowed($f, 'allow_view_groups')));
    $visible_limit = min(20, max(0, (int)setting('pc_nav_forum_count', '6')));
    $visible = array_slice($forums, 0, $visible_limit);
    $visible_ids = array_map(fn($f): int => (int)$f['id'], $visible);
    if ($active_forum && !in_array($active_forum, $visible_ids, true)) {
        foreach ($forums as $f) {
            if ((int)$f['id'] === $active_forum) {
                if ($visible_limit > 0) $visible[$visible_limit - 1] = $f;
                break;
            }
        }
    }
    $html = '<div class="top"><div class="bar"><button class="mobile-menu-button" type="button" data-mobile-menu-open aria-label="打开菜单" aria-controls="mobile-menu-drawer" aria-expanded="false"><svg width="19" height="19" viewBox="0 0 19 19" fill="none" aria-hidden="true"><path d="M3.5 5.5H15.5M3.5 9.5H15.5M3.5 13.5H15.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button><a class="brand" href="' . h(route_url('home')) . '">' . h($site_name) . '</a><nav class="forum-nav" aria-label="顶部版块">';
    foreach ($visible as $f) {
        $html .= '<a class="forum-link' . ((int)$f['id'] === $active_forum ? ' active' : '') . '" href="' . h(route_url('forum', ['id' => (int)$f['id']])) . '">' . h($f['name']) . '</a>';
    }
    foreach (top_menu_links(false, $mine) as $link) {
        $html .= '<a class="forum-link plugin-top-menu-link" href="' . h((string)$link['url']) . '">' . h((string)$link['text']) . '</a>';
    }
    $more_button_html = '';
    $more_panel_html = '';
    if (count($forums) > $visible_limit) {
        $more_button_html = '<button class="forum-more-toggle" type="button" data-forum-more-toggle aria-expanded="false" aria-controls="forum-more-region">全部版块</button>';
        $more_panel_html .= '<div class="forum-more-region" id="forum-more-region" hidden><div class="forum-more-panel"><a class="forum-more-link' . ($active_forum ? '' : ' active') . '" href="' . h(route_url('home')) . '">全部主题</a>';
        foreach ($forums as $f) $more_panel_html .= '<a class="forum-more-link' . ((int)$f['id'] === $active_forum ? ' active' : '') . '" href="' . h(route_url('forum', ['id' => (int)$f['id']])) . '">' . h($f['name']) . '</a>';
        $more_panel_html .= '</div></div>';
    }
    $search_html = '<form class="search-form" method="post" action="' . h(route_url('search')) . '" data-no-ajax="1">' . form_token() . '<select class="search-field" name="field" aria-label="搜索范围"><option value="title"' . ($search_field === 'title' ? ' selected' : '') . '>标题</option><option value="body"' . ($search_field === 'body' ? ' selected' : '') . '>内容</option><option value="reply"' . ($search_field === 'reply' ? ' selected' : '') . '>回帖</option></select><input class="search-input" type="search" name="q" placeholder="搜索关键词" value="' . h($q) . '" minlength="' . search_min_chars() . '"><button class="search-btn" type="submit" aria-label="搜索"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.4"/><path d="M9.5 9.5L13 13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg></button></form>';
    return $html . '</nav>' . $more_button_html . $search_html . '<a class="nav-mine" href="' . h($mine_link) . '">' . $mine_label . '</a></div></div>' . $more_panel_html . mobile_menu_html($mine, $forums);
}
function page_footer_html(string $title, string $flash): string
{
    $footer_html = (string)hook('page.footer', '', ['title' => $title]);
    $plugin_js = plugin_asset_tag('js');
    $footer_html .= sql_debug_html();
    return '<footer class="footer">' . $footer_html . '</footer>' . project_modal_html() . '<script>window.__pageFlash=' . json_encode($flash, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';</script><script src="' . h(app_url('app/assets/index.js')) . '?v=' . h(APP_VERSION) . '" defer></script>' . $plugin_js . '</body></html>';
}
function project_modal_html(): string
{
    return '<div class="modal-backdrop" id="notify-modal" hidden><div class="modal-panel"><div class="modal-head"><strong id="notify-modal-title">提示</strong><button type="button" class="modal-close" data-modal-close aria-label="关闭">×</button></div><div class="modal-body" id="notify-modal-body"></div></div></div><div class="toast" id="toast" hidden></div>';
}
function sql_debug_html(): string
{
    if (!SQL_DEBUG_MODE || uid() !== 1) return '';
    $queries = (array)($GLOBALS['__sql_queries'] ?? []);
    $html = '';
    foreach ($queries as $i => [$sql, $params]) {
        $html .= '<pre>' . h('#' . ($i + 1) . " " . $sql . ($params ? " " . json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) : '')) . '</pre>';
    }
    return '<details class="runtime-info" open><summary>SQL Debug · ' . count($queries) . ' queries</summary>' . $html . '</details>';
}
function page(string $title, string $body, array $seo = []): void
{
    $settings = settings_cache();
    $body = (string)hook('page.before_render', $body, ['title' => $title]);
    $site_name = trim((string)$settings['site_name']) ?: 'FORUM';
    $site_name_title = trim((string)($settings['site_name_title'] ?? '')) ?: $site_name;
    $is_home = ($_GET['a'] ?? 'home') === 'home' && trim((string)($_GET['q'] ?? '')) === '';
    $page_title = $is_home || $title === '' || $title === $site_name ? $site_name_title : $title . ' - ' . $site_name_title;
    $hook_seo = hook('page.seo', $seo, ['title' => $title, 'page_title' => $page_title]);
    if (is_array($hook_seo)) $seo = $hook_seo;
    $meta = '';
    $description = trim((string)($seo['description'] ?? ($settings['site_description'] ?? '')));
    if ($is_home && ($settings['site_keywords'] ?? '') !== '') $meta .= '<meta name="keywords" content="' . h($settings['site_keywords'] ?? '') . '">';
    if ($description !== '') $meta .= '<meta name="description" content="' . h($description) . '">';
    if (!empty($seo['canonical'])) $meta .= '<link rel="canonical" href="' . h((string)$seo['canonical']) . '">';
    $head_extra = (string)hook('page.head', '', ['title' => $title, 'page_title' => $page_title, 'seo' => $seo]);
    $flash = trim((string)($_COOKIE['__flash'] ?? ''));
    if ($flash !== '' && !headers_sent()) app_cookie('__flash', '', time() - 3600, true, false);
    $header_html = (string)hook('page.header', '', ['title' => $title]);
    echo page_head_html($page_title, $meta, $head_extra) . page_nav_html($site_name) . $header_html . '<main class="wrap">' . $body . '</main>' . page_footer_html($title, $flash);
}
function form_field_caption(string $label, string $help = ''): string
{
    return '<span>' . h($label) . ($help !== '' ? '<small>' . h($help) . '</small>' : '') . '</span>';
}
function input(string $label, string $name, mixed $value = '', string $type = 'text', bool $required = false, string $help = '', string $class = ''): string
{
    return '<label class="grid' . ($class !== '' ? ' ' . h($class) : '') . '">' . form_field_caption($label, $help) . '<input name="' . h($name) . '" type="' . h($type) . '" value="' . h($value) . '"' . ($required ? ' required' : '') . '></label>';
}
function textarea(string $label, string $name, mixed $value = '', bool $required = false, string $help = '', string $class = ''): string
{
    return '<label class="grid' . ($class !== '' ? ' ' . h($class) : '') . '">' . form_field_caption($label, $help) . '<textarea name="' . h($name) . '"' . ($required ? ' required' : '') . '>' . h($value) . '</textarea></label>';
}
function checkbox(string $label, string $name, bool $checked = false, string $help = '', string $class = ''): string
{
    return '<label class="grid' . ($class !== '' ? ' ' . h($class) : '') . '">' . form_field_caption($label, $help) . '<input type="checkbox" name="' . h($name) . '" value="1"' . ($checked ? ' checked' : '') . '></label>';
}
function number_input(string $label, string $name, mixed $value = '', int|float|null $min = null, int|float|null $max = null, bool $required = true, string $help = '', string $class = ''): string
{
    $limits = ($min !== null ? ' min="' . h($min) . '"' : '') . ($max !== null ? ' max="' . h($max) . '"' : '');
    return '<label class="grid' . ($class !== '' ? ' ' . h($class) : '') . '">' . form_field_caption($label, $help) . '<input name="' . h($name) . '" type="number" value="' . h($value) . '"' . $limits . ($required ? ' required' : '') . '></label>';
}
function select_input(string $label, string $name, mixed $value, array $options, string $help = '', string $class = ''): string
{
    $html = '<label class="grid' . ($class !== '' ? ' ' . h($class) : '') . '">' . form_field_caption($label, $help) . '<select name="' . h($name) . '">';
    foreach ($options as $option_value => $option_label) $html .= '<option value="' . h($option_value) . '"' . ((string)$option_value === (string)$value ? ' selected' : '') . '>' . h($option_label) . '</option>';
    return $html . '</select></label>';
}
function render_form_fields(array $fields, array $values = []): string
{
    $html = '';
    foreach ($fields as $name => $field) {
        if (isset($field['html'])) {
            $html .= (string)$field['html'];
            continue;
        }
        $type = (string)($field['type'] ?? 'text');
        $label = (string)($field['label'] ?? $name);
        $value = array_key_exists('value', $field) ? $field['value'] : ($values[$name] ?? '');
        $help = (string)($field['help'] ?? '');
        $class = (string)($field['class'] ?? '');
        if ($help !== '' && !str_contains(' ' . $class . ' ', ' settings-help-field ')) $class = trim($class . ' settings-help-field');
        if ($type === 'checkbox') $html .= checkbox($label, (string)$name, (bool)(int)$value, $help, $class);
        elseif ($type === 'number') $html .= number_input($label, (string)$name, $value, $field['min'] ?? null, $field['max'] ?? null, (bool)($field['required'] ?? true), $help, $class);
        elseif ($type === 'select') $html .= select_input($label, (string)$name, $value, (array)($field['options'] ?? []), $help, $class);
        elseif ($type === 'textarea') $html .= textarea($label, (string)$name, $value, !empty($field['required']), $help, $class);
        else $html .= input($label, (string)$name, $value, $type, !empty($field['required']), $help, $class);
    }
    return $html;
}
function select_forum(int $fid): string
{
    $options = [];
    foreach (forums_cache() as $f) if (forum_group_allowed($f, 'allow_post_groups')) $options[(int)$f['id']] = (string)$f['name'];
    if ($fid <= 0) $fid = (int)(array_key_first($options) ?? 0);
    return select_input('版块', 'forum_id', $fid, $options);
}
function default_post_forum_id(): int
{
    foreach (forums_cache() as $forum) if (forum_group_allowed($forum, 'allow_post_groups')) return (int)$forum['id'];
    return 0;
}
function can_manage_topic(array $t): bool
{
    $allowed = can_manage() || (uid() && (int)$t['user_id'] === uid());
    return hook('topic.can_manage', $allowed, ['topic' => $t]) === true;
}
function can_manage_reply(array $r): bool
{
    return can_manage() || (uid() && (int)$r['user_id'] === uid());
}
function can_admin_delete(string $type, int $id): bool
{
    if ($type === 'users') return can_manage() && $id !== uid() && ($id !== 1 || is_super_user());
    if (in_array($type, ['groups', 'forums'], true)) return can_manage() && is_super_user();
    $row = deletable_post_row($type, $id);
    if ($type === 'topics') return $row && can_manage_topic($row);
    if ($type === 'replies') return $row && can_manage_reply($row);
    return false;
}
function refresh_topic_stats(int $tid): void
{
    q("UPDATE app_topics SET reply_count=(SELECT COUNT(*) FROM app_replies WHERE topic_id=?),last_reply_at=COALESCE((SELECT created_at FROM app_replies WHERE topic_id=? ORDER BY created_at DESC,id DESC LIMIT 1),created_at),last_reply_user_id=COALESCE((SELECT user_id FROM app_replies WHERE topic_id=? ORDER BY created_at DESC,id DESC LIMIT 1),0) WHERE id=?", [$tid, $tid, $tid, $tid]);
}
function require_password_length(string $password): void
{
    if ((int)preg_match_all('/./us', $password) < PASSWORD_MIN_LENGTH) err('密码至少' . PASSWORD_MIN_LENGTH . '位');
}
function save_user(bool $admin = false, ?int $target_user_id = null): void
{
    $ip = ip_addr();
    $requested_user_id = id();
    $user_id = $admin ? $requested_user_id : ($target_user_id ?? $requested_user_id);
    if ($admin && $user_id === 1 && !is_super_user()) err('无权限');
    if (!$admin && $user_id > 0 && $user_id !== uid()) err('无权限');
    if (!$admin && $target_user_id === null && (array_key_exists('id', $_GET) || array_key_exists('id', $_POST))) err('参数错误');
    if (!$admin && !$user_id && hook('security.rate_allow', true, ['ip' => $ip, 'bucket' => 'register']) === false) err('同一IP 1小时内注册次数已达上限');
    $is_registration = !$admin && !$user_id;
    $username = post('username', 40);
    $email = post('email', 120);
    $bio = post('bio', 1000);
    $avatar_style = avatar_style(post('avatar_style', 40));
    $avatar_seed = post('avatar_seed', 80);
    if ($avatar_seed !== '') $avatar_seed = avatar_seed($avatar_style ?: 'dylan', $avatar_seed);
    $old_user = $user_id ? row('app_users', 'id', $user_id) : null;
    if ($user_id && !$old_user) err('用户不存在');
    if (!$admin && $old_user) {
        $username = (string)$old_user['username'];
        $email = (string)$old_user['email'];
    }
    if ($username === '') err('用户名不能为空');
    if (!$admin && (!$old_user || (string)$old_user['username'] !== $username) && hook('user.username_reserved', false, ['username' => $username]) === true) err('用户名已保留');
    $gid = $admin ? max(1, (int)$_POST['group_id']) : ($old_user ? (int)$old_user['group_id'] : (int)setting('default_group_id', '2'));
    if (!group_by_id($gid)) err('用户组不存在');
    $points = $admin ? (int)($_POST['points'] ?? 0) : (int)($old_user['points'] ?? 0);
    $is_banned = $admin ? (isset($_POST['is_banned']) ? 1 : 0) : (int)($old_user['is_banned'] ?? 0);
    $is_muted = $admin ? (isset($_POST['is_muted']) ? 1 : 0) : (int)($old_user['is_muted'] ?? 0);
    $pwd = (string)($_POST['password'] ?? '');
    $pwd2 = (string)($_POST['password2'] ?? '');
    if ($pwd !== '') require_password_length($pwd);
    if ($pwd !== '' && $pwd !== $pwd2) err('两次密码不一致');
    $filtered = hook('user.before_save', [
        'username' => $username,
        'email' => $email,
        'bio' => $bio,
        'avatar_style' => $avatar_style,
        'avatar_seed' => $avatar_seed,
        'group_id' => $gid,
        'points' => $points,
        'is_banned' => $is_banned,
        'is_muted' => $is_muted,
    ], ['id' => $user_id, 'admin' => $admin, 'creating' => !$user_id]);
    if (is_array($filtered)) {
        $username = cut((string)($filtered['username'] ?? $username), 40);
        $email = cut((string)($filtered['email'] ?? $email), 120);
        $bio = cut((string)($filtered['bio'] ?? $bio), 1000);
        $avatar_style = avatar_style(cut((string)($filtered['avatar_style'] ?? $avatar_style), 40));
        $avatar_seed = cut((string)($filtered['avatar_seed'] ?? $avatar_seed), 80);
        $gid = max(1, (int)($filtered['group_id'] ?? $gid));
        if (!group_by_id($gid)) err('用户组不存在');
        $points = (int)($filtered['points'] ?? $points);
        $is_banned = (int)($filtered['is_banned'] ?? $is_banned) ? 1 : 0;
        $is_muted = (int)($filtered['is_muted'] ?? $is_muted) ? 1 : 0;
    }
    if (!$admin && $old_user) {
        $username = (string)$old_user['username'];
        $email = (string)$old_user['email'];
    }
    if ($username === '') err('用户名不能为空');
    if ($is_registration && preg_match_all('/./us', $username) > 20) err('用户名不能超过20个汉字或英文');
    $exists = $user_id ? one("SELECT id FROM app_users WHERE username=? AND id<>?", [$username, $user_id]) : one("SELECT id FROM app_users WHERE username=?", [$username]);
    if ($exists) err('用户名已存在');
    if ($user_id) {
        $p = [$username, $email, $bio, $avatar_style, $avatar_seed, $gid, $is_banned, $is_muted, $user_id];
        $sql = "UPDATE app_users SET username=?,email=?,bio=?,avatar_style=?,avatar_seed=?,group_id=?,is_banned=?,is_muted=? WHERE id=?";
        if ($pwd !== '') {
            $sql = "UPDATE app_users SET username=?,email=?,bio=?,avatar_style=?,avatar_seed=?,group_id=?,is_banned=?,is_muted=?,password=? WHERE id=?";
            $p = [$username, $email, $bio, $avatar_style, $avatar_seed, $gid, $is_banned, $is_muted, password_hash($pwd, PASSWORD_DEFAULT), $user_id];
        }
        q($sql, $p);
        if ($pwd !== '' && $user_id === uid()) csrf_cookie_clear();
        if ($admin) user_points_set($user_id, $points, '管理员调整');
        fire('user.after_save', ['id' => $user_id, 'username' => $username, 'email' => $email, 'admin' => $admin, 'creating' => false]);
    } else {
        if ($pwd === '') err('密码不能为空');
        q("INSERT INTO app_users(username,password,email,bio,avatar_style,avatar_seed,group_id,points,is_banned,is_muted,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", [$username, password_hash($pwd, PASSWORD_DEFAULT), $email, $bio, $avatar_style, $avatar_seed, $gid, $points, $is_banned, $is_muted, now()]);
        $new_user_id = app_db_last_insert_id('app_users');
        $GLOBALS['__last_saved_user_id'] = $new_user_id;
        if (!$admin && !id()) fire('security.rate_hit', ['ip' => $ip, 'bucket' => 'register']);
        fire('user.after_save', ['id' => $new_user_id, 'username' => $username, 'email' => $email, 'admin' => $admin, 'creating' => true]);
        if (!$admin) home_stats_record_insert('users', $new_user_id);
    }
}
function user_notify_page(): void
{
    need_login();
    $target = row('app_users', 'id', id()) ?: err('用户不存在');
    if ((int)$target['id'] === uid()) err('不能通知自己');
    if (is_post_request()) {
        $quote = notification_excerpt((string)($_POST['quote'] ?? ''), 100);
        $body = post('content', 10000);
        $content = trim(($quote !== '' ? '> ' . $quote . "\n\n" : '') . $body);
        if ($content === '') err('通知内容不能为空');
        create_notification((int)$target['id'], uid(), 'direct', $content);
        if (ajax_request()) json_response(['ok' => 1, 'message' => '已发送']);
        go(route_url('user', ['id' => (int)$target['id'], 'tab' => 'notifications']));
    }
    $target['group_name'] = (group_by_id((int)$target['group_id']) ?: ['name' => '用户'])['name'];
    $quote = notification_excerpt((string)($_GET['quote'] ?? ''), 100);
    $quote_html = $quote !== '' ? '<blockquote class="notify-quote-card"><p>' . h($quote) . '</p></blockquote><input type="hidden" name="quote" value="' . h($quote) . '">' : '';
    $html = '<div class="notify-pop"><div class="notify-target"><div class="notify-target-avatar">' . avatar_link_tag((int)$target['id'], (string)$target['username'], (string)$target['avatar_style'], '', (string)$target['avatar_seed']) . '</div><div class="notify-target-info"><strong>' . h($target['username']) . '</strong><span>' . h($target['group_name']) . '</span></div></div><form class="notify-form" method="post" action="' . h(route_url('notify', ['id' => (int)$target['id']])) . '">' . form_token() . $quote_html . '<textarea name="content" maxlength="10000" placeholder="输入私信内容" required></textarea><div class="notify-actions"><span class="notify-status"></span><button type="submit">发送</button></div></form></div>';
    if (ajax_request()) {
        echo $html;
        exit;
    }
    page('通知TA', form_shell('<div class="form-panel"><h2>通知TA</h2>' . $html . '</div>', me()));
}
function base_url(): string
{
    $configured = clean_site_base_url(setting('site_base_url', ''));
    if ($configured !== '') return $configured;
    $host = preg_replace('/[^A-Za-z0-9.\-:]/', '', (string)($_SERVER['HTTP_HOST'] ?? 'localhost')) ?: 'localhost';
    return (auth_cookie_secure() ? 'https' : 'http') . '://' . $host;
}
function absolute_url(string $url): string
{
    if (preg_match('/^https?:\/\//i', $url)) return $url;
    return rtrim(base_url(), '/') . '/' . ltrim($url, '/');
}
function seo_text(string $text, int $max = 160): string
{
    $text = html_entity_decode(strip_tags(markdown_html($text)), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    return cut(trim(preg_replace('/\s+/u', ' ', $text) ?? ''), $max);
}
function page_seo(string $route, array $params = [], string $description = ''): array
{
    $seo = ['canonical' => absolute_url(route_url($route, $params))];
    $description = seo_text($description);
    if ($description !== '') $seo['description'] = $description;
    return $seo;
}
function save_forum(): void
{
    $name = post('name', 80);
    if ($name === '') err('版块名不能为空');
    $description = post('description', 300);
    $sort = (int)$_POST['sort'];
    $permissions = [];
    foreach (['allow_view_groups', 'allow_post_groups', 'allow_reply_groups'] as $field) {
        $permissions[$field] = implode(',', array_values(array_unique(array_filter(array_map('intval', (array)($_POST[$field] ?? []))))));
    }
    id()
        ? q("UPDATE app_forums SET name=?,description=?,sort=?,allow_view_groups=?,allow_post_groups=?,allow_reply_groups=? WHERE id=?", [$name, $description, $sort, ...array_values($permissions), id()])
        : q("INSERT INTO app_forums(name,description,sort,allow_view_groups,allow_post_groups,allow_reply_groups) VALUES(?,?,?,?,?,?)", [$name, $description, $sort, ...array_values($permissions)]);
    forums_cache(true);
}
function save_group(): void
{
    $name = post('name', 60);
    if ($name === '') err('组名不能为空');
    $allow_manage = isset($_POST['allow_manage']) ? 1 : 0;
    $allow_admin = isset($_POST['allow_admin']) ? 1 : 0;
    id() ? q("UPDATE app_groups SET name=?,allow_manage=?,allow_admin=? WHERE id=?", [$name, $allow_manage, $allow_admin, id()]) : q("INSERT INTO app_groups(name,allow_manage,allow_admin) VALUES(?,?,?)", [$name, $allow_manage, $allow_admin]);
    groups_cache(true);
}
function content_create_author(string $hook_name, array $content, array $context, array $limits): array
{
    $defaults = ['user_id' => uid()] + $content;
    $author = hook($hook_name, $defaults, $context);
    $author = is_array($author) ? $author + $defaults : $defaults;
    $author['user_id'] = max(1, (int)$author['user_id']);
    foreach ($limits as $field => $max) $author[$field] = cut((string)$author[$field], $max);
    return $author;
}
function save_topic(): int
{
    need_speak();
    $topic_id = id();
    if (!$topic_id) check_post_interval();
    $action = (string)($_POST['topic_action'] ?? '');
    $fid = max(0, (int)$_POST['forum_id']);
    if ($fid <= 0) $fid = default_post_forum_id();
    $forum = forum_by_id($fid) ?: err('版块不存在');
    $title = post('title', 120);
    $body = post('body', 20000);
    $filtered = hook('topic.before_save', ['title' => $title, 'body' => $body, 'forum_id' => $fid], ['id' => $topic_id, 'action' => $action]);
    if (is_array($filtered)) {
        $title = cut((string)($filtered['title'] ?? $title), 120);
        $body = cut((string)($filtered['body'] ?? $body), 20000);
        $next_fid = max(1, (int)($filtered['forum_id'] ?? $fid));
        if ($next_fid !== $fid) {
            $fid = $next_fid;
            $forum = forum_by_id($fid) ?: err('版块不存在');
        }
    }
    if ($topic_id) {
        $t = row('app_topics', 'id', $topic_id) ?: err('主题不存在');
        if (!can_manage_topic($t)) err('无权限');
        if ($action !== '' && !can_manage()) err('无权限');
        if ($action === 'delete') {
            del('topics', (int)$t['id']);
            go(route_url('home'));
        }
        if (in_array($action, ['pin', 'unpin'], true)) {
            set_pinned_topic((int)$t['id'], $action === 'pin');
            go(route_url('topic', ['id' => (int)$t['id']]));
        }
        if ($action === 'highlight') {
            $raw_color = trim((string)($_POST['highlight_style'] ?? ''));
            $style = $raw_color === '' ? '' : 'color:' . (preg_match('/^#[0-9a-fA-F]{6}$/', $raw_color, $m) ? $m[0] : '#d94b4b');
            q("UPDATE app_topics SET highlight_style=? WHERE id=?", [$style, (int)$t['id']]);
            go(route_url('topic', ['id' => (int)$t['id']]));
        }
        if ($action === 'mute_author') {
            if ((int)$t['user_id'] === 1) err('不能操作超级管理员');
            q("UPDATE app_users SET is_muted=1 WHERE id=?", [(int)$t['user_id']]);
            go(route_url('topic', ['id' => (int)$t['id']]));
        }
        if ($action === '') {
            if (!forum_group_allowed($forum, 'allow_post_groups')) err('无权限');
            if ($title === '' || $body === '') err('标题和内容不能为空');
        } else {
            $title = (string)($t['title'] ?? '');
            $body = (string)($t['body'] ?? '');
        }
        $reply_order = (int)($_POST['reply_order'] ?? 0) === 1 ? 1 : 0;
        tx(function () use ($topic_id, $fid, $title, $body, $reply_order) {
            q("UPDATE app_topics SET forum_id=?,title=?,body=?,reply_order=? WHERE id=?", [$fid, $title, $body, $reply_order, $topic_id]);
            topic_fts_sync($topic_id, $title, $body);
        });
        fire('topic.after_save', ['id' => $topic_id, 'forum_id' => $fid, 'title' => $title, 'body' => $body, 'editing' => true]);
        return $topic_id;
    }
    if (!forum_group_allowed($forum, 'allow_post_groups')) err('无权限');
    if ($title === '' || $body === '') err('标题和内容不能为空');
    $author = content_create_author('topic.create_author', ['title' => $title, 'body' => $body], ['forum_id' => $fid], ['title' => 120, 'body' => 20000]);
    $author_id = (int)$author['user_id'];
    $title = $author['title'];
    $body = $author['body'];
    if ($title === '' || $body === '') err('标题和内容不能为空');
    $ts = now();
    $tid = tx(function () use ($fid, $author_id, $title, $body, $ts) {
        q("INSERT INTO app_topics(forum_id,user_id,title,body,created_at,last_reply_at) VALUES(?,?,?,?,?,?)", [$fid, $author_id, $title, $body, $ts, $ts]);
        $tid = app_db_last_insert_id('app_topics');
        topic_fts_sync($tid, $title, $body);
        q("UPDATE app_users SET last_post_at=? WHERE id=?", [$ts, $author_id]);
        create_topic_notifications($tid, $body, $author_id);
        return $tid;
    });
    home_stats_refresh_topics();
    fire('topic.after_save', ['id' => $tid, 'forum_id' => $fid, 'title' => $title, 'body' => $body, 'user_id' => $author_id, 'editing' => false]);
    return $tid;
}
function save_reply(): array
{
    need_speak();
    $reply_id = id();
    if (!$reply_id) check_post_interval();
    $r = null;
    if ($reply_id) {
        $r = row('app_replies', 'id', $reply_id) ?: err('回复不存在');
        if (!can_manage_reply($r)) err('无权限');
        if (trim((string)$r['body']) === '') err('回复已删除，无法编辑');
        $tid = (int)$r['topic_id'];
    } else {
        $tid = max(1, (int)$_POST['topic_id']);
    }
    $topic = row('app_topics', 'id', $tid) ?: err('主题不存在');
    $forum = forum_by_id((int)$topic['forum_id']) ?: err('版块不存在');
    if (!forum_group_allowed($forum, 'allow_reply_groups')) err('无权限');
    $body = post('body', 10000);
    $filtered = hook('reply.before_save', ['body' => $body, 'topic_id' => $tid], ['id' => $reply_id]);
    if (is_array($filtered)) $body = cut((string)($filtered['body'] ?? $body), 10000);
    if ($body === '') err('回复不能为空');
    if ($reply_id) {
        tx(function () use ($body, $tid, $reply_id) {
            q("UPDATE app_replies SET body=?,updated_at=? WHERE id=? AND topic_id=?", [$body, now(), $reply_id, $tid]);
            reply_fts_sync($reply_id, $body);
        });
        fire('reply.after_save', ['id' => (int)$r['id'], 'topic_id' => (int)$r['topic_id'], 'body' => $body, 'editing' => true]);
        return ['topic_id' => (int)$r['topic_id'], 'reply_id' => (int)$r['id']];
    }
    $author = content_create_author('reply.create_author', ['body' => $body], ['topic_id' => $tid], ['body' => 10000]);
    $author_id = (int)$author['user_id'];
    $body = $author['body'];
    if ($body === '') err('回复不能为空');
    $ts = now();
    $rid = tx(function () use ($tid, $author_id, $body, $ts) {
        q("INSERT INTO app_replies(topic_id,user_id,body,created_at,updated_at) VALUES(?,?,?,?,?)", [$tid, $author_id, $body, $ts, $ts]);
        $rid = app_db_last_insert_id('app_replies');
        reply_fts_sync($rid, $body);
        q("UPDATE app_users SET last_post_at=? WHERE id=?", [$ts, $author_id]);
        q("UPDATE app_topics SET reply_count=reply_count+1,last_reply_at=?,last_reply_user_id=? WHERE id=?", [$ts, $author_id, $tid]);
        create_reply_notifications($tid, $rid, $body, $author_id);
        return $rid;
    });
    home_stats_record_insert('replies', $rid);
    fire('reply.after_save', ['id' => $rid, 'topic_id' => $tid, 'body' => $body, 'user_id' => $author_id, 'editing' => false]);
    return ['topic_id' => $tid, 'reply_id' => $rid];
}
function del(string $table, int $id): void
{
    $tables = [
        'users' => 'app_users',
        'groups' => 'app_groups',
        'forums' => 'app_forums',
        'topics' => 'app_topics',
        'replies' => 'app_replies',
    ];
    if (!isset($tables[$table])) err('参数错误');
    if (in_array($table, ['users', 'groups', 'forums'], true) && !can_manage()) err('无权限');
    if ($table === 'users' && $id === uid()) err('不能删除自己');
    if ($table === 'groups' && $id <= 2) err('内置用户组不能删除');
    if ($table === 'groups' && $id === (int)setting('default_group_id', '2')) err('默认用户组不能删除');
    if ($table === 'forums' && count(forums_cache()) <= 1) err('至少保留一个版块');
    if (in_array($table, ['users', 'topics', 'replies'], true)) {
        $record = row($tables[$table], 'id', $id) ?: err('记录不存在');
        $affected_topics = $table === 'users' ? q("SELECT DISTINCT topic_id FROM app_replies WHERE user_id=?", [$id])->fetchAll() : [];
        tx(function () use ($table, $tables, $id, $record, $affected_topics) {
            if ($table === 'replies') {
                if (trim((string)$record['body']) === '') err('回复已删除');
                reply_fts_delete($id);
                q("UPDATE app_replies SET body='' WHERE id=?", [$id]);
                return;
            }
            if ($table === 'topics') fire('topic.before_delete', ['id' => $id, 'row' => $record]);
            fire('content.before_delete', ['table' => $table, 'row' => $record]);
            if ($table === 'topics') topic_fts_delete($id);
            q('DELETE FROM ' . $tables[$table] . ' WHERE id=?', [$id]);
            if ($table === 'users') {
                foreach ($affected_topics as $topic) refresh_topic_stats((int)$topic['topic_id']);
            }
        });
        if ($table === 'topics') home_stats_refresh_topics();
        return;
    }
    tx(fn() => q('DELETE FROM ' . $tables[$table] . ' WHERE id=?', [$id]));
    if ($table === 'forums') forums_cache(true);
    else groups_cache(true);
}
function login_page(): void
{
    if (uid()) go(consume_auth_return_url());
    if (is_post_request()) {
        $ip = ip_addr();
        if (hook('security.rate_allow', true, ['ip' => $ip, 'bucket' => 'login_fail']) === false) err('同一IP 1小时内错误次数已达上限');
        hook('login.before_submit', true, []);
        $u = one("SELECT id,password FROM app_users WHERE username=?", [post('username', 40)]);
        if ($u && password_verify((string)$_POST['password'], $u['password'])) {
            $auth = hook('auth.password_verified', ['continue' => true, 'user_id' => (int)$u['id']], ['user' => $u]);
            if (!is_array($auth) || !empty($auth['continue'])) complete_login((int)$u['id']);
            return;
        }
        fire('security.rate_hit', ['ip' => $ip, 'bucket' => 'login_fail']);
        err('用户名或密码错误');
    }
    $sidebar = sidebar_stack_html([
        sidebar_notice_card_html('登录注意事项', ['请使用用户名登录。', '密码区分大小写。', '公共设备登录后请及时退出。']),
    ]);
    $form_extra = (string)hook('login.form_extra', '', []);
    $auth_extra = (string)hook('login.after_form', '', []);
    page('登录', shell_html(auth_tabs_html('login') . '<div class="form-panel auth-panel"><h2>登录</h2><form method="post">' . form_token() . input('用户名', 'username', '', 'text', true) . input('密码', 'password', '', 'password', true) . $form_extra . '<button>登录</button></form>' . $auth_extra . '</div>', $sidebar));
}
function register_page(): void
{
    if (uid()) go(consume_auth_return_url());
    if (setting('allow_register', '1') !== '1') err('注册已关闭');
    if (is_post_request()) {
        if (array_key_exists('id', $_GET) || array_key_exists('id', $_POST)) err('参数错误');
        save_user(false);
        $user_id = (int)($GLOBALS['__last_saved_user_id'] ?? 0);
        if ($user_id <= 0) err('注册失败');
        start_cookie_login($user_id);
        go(consume_auth_return_url());
    }
    $sidebar = sidebar_stack_html([
        sidebar_notice_card_html('注册注意事项', ['邮箱信息不会公开。', '请不要使用保留用户名或冒充他人。']),
    ]);
    $form_extra = (string)hook('register.form_extra', '', []);
    $username = '<label class="grid"><span>用户名<small>不超过20个汉字或英文</small></span><input name="username" type="text" maxlength="20" required></label>';
    page('注册', shell_html(auth_tabs_html('register') . '<div class="form-panel auth-panel"><h2>注册</h2><form method="post">' . form_token() . $username . input('密码', 'password', '', 'password', true) . input('确认密码', 'password2', '', 'password', true) . input('邮箱', 'email', '', 'email') . $form_extra . '<button>注册</button></form></div>', $sidebar));
}
function profile_page(): void
{
    need_login();
    $u = me();
    if (is_post_request()) {
        save_user(false, uid());
        set_flash('个人资料已保存');
        go(route_url('profile'));
    }
    $account_cards = '<div class="profile-account-grid"><div class="profile-account-card"><span>用户名</span><strong>' . h($u['username']) . '</strong></div><div class="profile-account-card"><span>用户 UID</span><strong>' . (int)$u['id'] . '</strong></div><div class="profile-account-card"><span>邮箱</span><strong title="' . h($u['email']) . '">' . h($u['email']) . '</strong></div><div class="profile-account-card"><span>注册时间</span><strong>' . date('Y-m-d H:i', (int)$u['created_at']) . '</strong></div><div class="profile-account-card"><span>积分</span><strong>' . (int)$u['points'] . '</strong></div><div class="profile-account-card profile-account-logout"><form class="post-action-form" method="post" action="' . h(route_url('logout')) . '">' . form_token() . '<button class="profile-exit-button" type="submit"><span>账号安全</span><strong>安全退出</strong></button></form></div></div>';
    $password_fields = '<div class="grid profile-disclosure" data-profile-disclosure><div><div class="profile-disclosure-summary"><span class="profile-disclosure-heading"><span>密码</span><small>不修改密码</small></span><button class="profile-edit-action" type="button" data-profile-toggle aria-expanded="false">修改</button></div><div class="profile-disclosure-detail is-hidden" data-profile-edit>' . input('新密码', 'password', '', 'password') . input('确认密码', 'password2', '', 'password') . '</div></div></div>';
    $profile_extra = (string)hook('profile.after_form', '', ['user' => $u]);
    page('个人资料', form_shell('<div class="form-panel"><h2>个人资料</h2>' . $account_cards . '<form method="post">' . form_token() . avatar_picker_html($u) . textarea('简介', 'bio', $u['bio']) . $password_fields . '<button>保存</button></form>' . $profile_extra . '</div>', $u));
}
function user_page(): void
{
    $username = is_string($_GET['username'] ?? null) ? cut(trim((string)$_GET['username']), 40) : '';
    if ($username === '' && uid() && id() === uid()) $user = me();
    else {
        $user = $username !== ''
            ? one("SELECT id,username,bio,avatar_style,avatar_seed,group_id,points,is_banned,is_muted FROM app_users WHERE username=?", [$username])
            : row('app_users', 'id', id());
    }
    $user = $user ?: err('你访问的页面不存在', 404);
    $g = group_by_id((int)$user['group_id']) ?: ['name' => '用户'];
    $user['group_name'] = $g['name'];
    $tab = $_GET['tab'] ?? 'topics';
    if ($tab === 'notify') user_notify_page();
    else topic_index_page(null, $user);
}
function topic_index_sort(bool $profile): string
{
    if ($profile) return 'post';
    if (!array_key_exists('sort', $_GET)) return (($_COOKIE['__topic_index_sort'] ?? 'comment') === 'post') ? 'post' : 'comment';
    if (in_array($_GET['sort'], ['lottery', 'card'], true)) return (string)$_GET['sort'];
    $sort = $_GET['sort'] === 'post' ? 'post' : 'comment';
    app_cookie('__topic_index_sort', $sort, time() + COOKIE_TTL, false);
    $_COOKIE['__topic_index_sort'] = $sort;
    return $sort;
}
function topic_index_data(int $fid, ?array $user, string $profile_tab, string $query, string $search_field, string $sort, int $page, int $size): array
{
    $profile_uid = (int)($user['id'] ?? 0);
    $offset = ($page - 1) * $size;
    $ctx = ['forum_id' => $fid, 'user' => $user, 'profile_tab' => $profile_tab, 'query' => $query, 'search_field' => $search_field, 'sort' => $sort, 'page' => $page, 'page_size' => $size, 'offset' => $offset];
    $data = hook('topic.index_data.load', null, $ctx);
    if (is_array($data)) return $data;
    $simple_pagination = false;
    $has_next_page = false;
    $profile_empty = '';
    $unread_total = 0;
    $where_parts = [];
    $params = [];
    if ($fid) {
        $where_parts[] = 'forum_id=?';
        $params[] = $fid;
    }
    if ($query !== '' && $search_field !== 'reply') {
        [$condition, $search_params] = content_search_condition($query, $search_field);
        $where_parts[] = '(' . $condition . ')';
        $params = array_merge($params, $search_params);
    }
    $where = $where_parts ? 'WHERE ' . implode(' AND ', $where_parts) : '';
    $profile_data = $profile_uid ? hook('user.profile_tab_data', null, ['user' => $user, 'tab' => $profile_tab, 'page' => $page, 'page_size' => $size, 'offset' => $offset]) : null;
    if (is_array($profile_data)) {
        $rows = is_array($profile_data['rows'] ?? null) ? $profile_data['rows'] : [];
        $total = max(0, (int)($profile_data['total'] ?? count($rows)));
        $profile_empty = trim((string)($profile_data['empty'] ?? ''));
    } elseif ($profile_uid && $profile_tab === 'notifications') {
        $total = notifications_total($profile_uid);
        $unread_total = notifications_unread_total($profile_uid);
        $rows = notifications_list($profile_uid, $size, $offset);
    } elseif ($profile_uid && $profile_tab === 'replies') {
        $total = (int)val("SELECT COUNT(*) FROM app_replies WHERE user_id=?", [$profile_uid]);
        $reply_rows = q("SELECT id,topic_id,user_id,body,created_at FROM app_replies WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?", [$profile_uid, $size, $offset])->fetchAll();
        $rows = topic_list_rows_for_replies($reply_rows);
    } elseif ($query !== '' && $search_field === 'reply') {
        [$reply_condition, $reply_params] = content_search_condition($query, 'reply');
        $reply_where = '(' . $reply_condition . ')' . ($profile_uid ? ' AND user_id=?' : '');
        $reply_rows = q("SELECT id,topic_id,user_id,body,created_at FROM app_replies WHERE $reply_where ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?", array_merge($reply_params, $profile_uid ? [$profile_uid] : [], [$size + 1, $offset]))->fetchAll();
        $total = 0;
        $simple_pagination = true;
        $has_next_page = count($reply_rows) > $size;
        $rows = topic_list_rows_for_replies(array_slice($reply_rows, 0, $size));
    } else {
        if ($profile_uid) {
            $where = $where ? $where . ' AND user_id=?' : 'WHERE user_id=?';
            $params[] = $profile_uid;
        }
        $total = $query !== '' ? 0 : (($fid || $profile_uid) ? (int)val("SELECT COUNT(*) FROM app_topics $where", $params) : (int)stats_cache()['topics']);
        $order = $sort === 'post' ? 'created_at DESC,id DESC' : 'last_reply_at DESC,id DESC';
        $index_hint = db_driver() === 'mysql' && $query === '' && !$fid && !$profile_uid ? ' FORCE INDEX (' . ($sort === 'post' ? 'idx_topics_created' : 'idx_topics_last_reply') . ')' : '';
        $query_size = $query !== '' ? $size + 1 : $size;
        $rows = q("SELECT " . topic_list_select_columns() . " FROM app_topics$index_hint $where ORDER BY $order LIMIT ? OFFSET ?", array_merge($params, [$query_size, $offset]))->fetchAll();
        if ($query !== '') {
            $simple_pagination = true;
            $has_next_page = count($rows) > $size;
            $rows = array_slice($rows, 0, $size);
        }
        $pinned_ids = (!$profile_uid && !$fid && $query === '' && $page === 1) ? pinned_topic_ids() : [];
        if ($pinned_ids) {
            $pinned_rows = rows_by_ids('app_topics', $pinned_ids, topic_list_select_columns());
            $ordered = [];
            foreach ($pinned_ids as $pinned_id) {
                if (isset($pinned_rows[$pinned_id])) $ordered[] = $pinned_rows[$pinned_id] + ['is_pinned' => 1];
            }
            $rows = array_merge($ordered, array_values(array_filter($rows, fn($row) => !isset($pinned_rows[(int)$row['id']]))));
        }
        $rows = attach_topic_list_users($rows);
    }
    $data = [
        'rows' => $rows,
        'total' => $total,
        'profile_empty' => $profile_empty,
        'unread_total' => $unread_total,
        'simple_pagination' => $simple_pagination,
        'has_next_page' => $has_next_page,
    ];
    hook('topic.index_data.loaded', $data, $ctx);
    return $data;
}
function topic_index_page(?array $filter_forum = null, ?array $filter_user = null): void
{
    $fid = (int)($filter_forum['id'] ?? 0);
    $profile_uid = (int)($filter_user['id'] ?? 0);
    $own_profile = $profile_uid && uid() === $profile_uid;
    $url = function (string $query) use ($profile_uid, $fid): string {
        parse_str($query, $params);
        if ($profile_uid) return route_url('user', ['id' => $profile_uid] + $params);
        if ($fid) return route_url('forum', ['id' => $fid] + $params);
        return route_url('home', $params);
    };
    $p = max(1, (int)($_GET['p'] ?? 1));
    $size = max(1, (int)setting('topics_per_page', '30'));
    $off = ($p - 1) * $size;
    $profile_tab = (string)($_GET['tab'] ?? 'topics');
    $sort = topic_index_sort($profile_uid > 0);
    $q = trim((string)($_GET['q'] ?? ''));
    $search_field = topic_search_field((string)($_GET['field'] ?? 'title'));
    $profile_tabs = [
        'topics' => ['label' => '主题', 'href' => $url('tab=topics')],
        'replies' => ['label' => '回帖', 'href' => $url('tab=replies')],
    ];
    if ($own_profile) $profile_tabs['notifications'] = ['label' => '通知', 'href' => $url('tab=notifications')];
    if ($profile_uid) {
        $hook_tabs = hook('user.profile_tabs', $profile_tabs, ['user' => $filter_user, 'self' => $own_profile, 'query' => $q, 'field' => $search_field]);
        if (is_array($hook_tabs)) $profile_tabs = $hook_tabs;
        if (!array_key_exists($profile_tab, $profile_tabs)) $profile_tab = 'topics';
    }
    if ($own_profile) {
        $profile_tabs['profile_settings'] = ['label' => '设置', 'href' => route_url('profile'), 'class' => 'tab-mobile-action'];
        if (can_access_admin()) $profile_tabs['admin'] = ['label' => '后台', 'href' => route_url('admin'), 'class' => 'tab-mobile-action'];
    }
    require_search_min_chars($q);
    if ($q !== '') {
        if (!uid()) err('请登录后操作');
        $seconds = post_interval_seconds();
        if ($seconds > 0) {
            $wait = $seconds - (time() - (int)(row('app_users', 'id', uid())['last_post_at'] ?? 0));
            if ($wait > 0) err('搜索太频繁，请 ' . $wait . ' 秒后再试');
            q("UPDATE app_users SET last_post_at=? WHERE id=?", [time(), uid()]);
        }
    }
    $data = topic_index_data($fid, $filter_user, $profile_tab, $q, $search_field, $sort, $p, $size);
    $rows = $data['rows'];
    $total = $data['total'];
    $profile_empty = $data['profile_empty'];
    $unread_total = $data['unread_total'];
    $main = '';
    $search_query = $q !== '' ? 'q=' . rawurlencode($q) . '&field=' . $search_field . '&' : '';
    if ($profile_uid) {
        $main .= '<div class="profile-toolbar">' . tab_bar_html($profile_tabs, $profile_tab) . '</div>';
    } else {
        if (!$profile_uid && $q === '') {
            $forum_links = '<div class="mobile-forum-strip"><a class="mobile-forum-link' . ($fid ? '' : ' active') . '" href="' . h(route_url('home')) . '">全部</a>';
            foreach (forums_cache() as $f) {
                if (!forum_group_allowed($f, 'allow_view_groups')) continue;
                $forum_links .= '<a class="mobile-forum-link' . ((int)$f['id'] === $fid ? ' active' : '') . '" href="' . h(route_url('forum', ['id' => (int)$f['id']])) . '">' . h($f['name']) . '</a>';
            }
            $main .= $forum_links . '</div>';
        }
        $tab_items = [
            'comment' => ['label' => '新评论', 'href' => $url($search_query . 'sort=comment')],
            'post' => ['label' => '新帖子', 'href' => $url($search_query . 'sort=post')],
        ];
        $hook_tabs = hook('topic.index_tabs', $tab_items, ['forum_id' => $fid, 'query' => $q, 'sort' => $sort]);
        if (is_array($hook_tabs)) $tab_items = $hook_tabs;
        $toolbar_actions = (string)hook('topic.toolbar_actions', '', ['forum_id' => $fid, 'query' => $q, 'sort' => $sort]);
        $main .= '<div class="topic-toolbar">' . tab_bar_html($tab_items, $sort) . $toolbar_actions . (can_speak() ? '<a class="tab-post" href="' . h(route_url('topic_edit', ['fid' => $fid ?: null])) . '">+ 发帖</a>' : '') . '</div>';
    }
    $profile_header = $profile_uid
        ? (string)hook('user.profile_tab_header', '', [
            'user' => $filter_user,
            'self' => $own_profile,
            'tab' => $profile_tab,
            'page' => $p,
            'page_size' => $size,
            'total' => $total,
        ])
        : '';
    $main .= $profile_header . '<ul class="post-list">';
    if ($profile_uid && $profile_tab === 'notifications') {
        mark_notifications_read($profile_uid, $unread_total);
        if (!$rows) $main .= '<li class="empty-state">暂无通知</li>';
        else foreach ($rows as $i => $n) {
            if ($unread_total > 0 && $off + $i === $unread_total) $main .= '<li class="notification-read-divider">下面的通知已读</li>';
            $main .= notification_row_html($n);
        }
    } elseif (!$rows) {
        $empty = $profile_empty !== '' ? $profile_empty : ($profile_uid ? ($profile_tab === 'replies' ? '暂无回帖' : '暂无主题') : '暂无主题');
        $main .= '<li class="empty-state">' . ($q !== '' ? '没有找到匹配的' . ($search_field === 'reply' ? '回帖' : '主题') : $empty) . '</li>';
    } else {
        foreach ($rows as $t) {
            $time = (int)($t['list_time'] ?? $t['my_reply_at'] ?? ($sort === 'post' ? $t['created_at'] : ($t['last_reply_at'] ?: $t['created_at'])));
            $t['time'] = $time;
            $t['forum'] = forum_by_id((int)$t['forum_id']) ?: ['id' => 0, 'name' => ''];
            $main .= topic_list_row($t, $sort);
        }
    }
    $page_query = $search_query . ($profile_uid ? 'tab=' . $profile_tab : 'sort=' . $sort);
    if ($data['simple_pagination']) $pagination = simple_paginate($p > 1, $data['has_next_page'], $p, $url($page_query));
    else $pagination = paginate($total, $p, $size, $url($page_query));
    $main .= '</ul>' . ($pagination !== '' ? '<div class="pagination-bar">' . $pagination . '</div>' : '');
    if ($profile_uid) $main .= (string)hook('user.profile_tab_footer', '', ['user' => $filter_user, 'self' => $own_profile, 'tab' => $profile_tab, 'page' => $p, 'page_size' => $size, 'total' => $total]);
    $sidebar_user = $profile_uid ? $filter_user : null;
    $is_home_first_page = !$profile_uid && !$filter_forum && $q === '' && $p === 1;
    $sidebar = sidebar_stack_html([sidebar_user_card_html($sidebar_user, false, $fid), sidebar_bio_card_html($filter_user), (!$profile_uid ? quick_forums_html() . ($is_home_first_page ? sidebar_stats_card_html() : '') : '')], ['is_home_first_page' => $is_home_first_page]);
    $title = $profile_uid ? $filter_user['username'] : ($filter_forum ? $filter_forum['name'] : '首页');
    $seo = [];
    if ($profile_uid) $seo = page_seo('user', ['id' => $profile_uid], (string)($filter_user['bio'] ?? $filter_user['username']));
    elseif ($filter_forum) $seo = page_seo('forum', ['id' => $fid], (string)($filter_forum['description'] ?? $filter_forum['name']));
    $shell_class = $profile_uid
        ? 'profile-mobile-sidebar' . ($own_profile ? ' profile-mobile-sidebar-own' : '')
        : ($is_home_first_page ? 'home-mobile-sidebar' : '');
    page($title, shell_html($main, $sidebar, $shell_class), $seo);
}
function home_page(): void
{
    topic_index_page();
}
function search_page(): void
{
    if (!uid()) err('请登录后操作');
    if (!is_post_request()) go(route_url('home'));
    $q = post('q', 120);
    if ($q === '') go(route_url('home'));
    require_search_min_chars($q);
    $field = (string)($_POST['field'] ?? 'title');
    if (!in_array($field, ['title', 'body', 'reply'], true)) $field = 'title';
    go(route_url('home', ['q' => $q, 'field' => $field]));
}
function forum_page(): void
{
    $fid = id();
    $f = forum_by_id($fid) ?: err('你访问的页面不存在', 404);
    if (!forum_group_allowed($f, 'allow_view_groups')) err('无权限');
    remember_forum($fid);
    topic_index_page($f);
}
function topic_page_replies(array $topic, int $page, int $size, int $offset, bool $reply_desc): array
{
    $ctx = ['topic' => $topic, 'page' => $page, 'page_size' => $size, 'offset' => $offset, 'reply_order' => $reply_desc ? 1 : 0];
    $data = hook('topic.replies_data.load', null, $ctx);
    if (is_array($data)) return $data;
    $order = $reply_desc ? 'created_at DESC,id DESC' : 'created_at,id';
    $replies = q("SELECT * FROM app_replies WHERE topic_id=? ORDER BY $order LIMIT ? OFFSET ?", [(int)$topic['id'], $size, $offset])->fetchAll();
    $posts = attach_users(array_merge([$topic], $replies));
    $topic = array_shift($posts);
    $replies = $posts;
    $data = ['topic' => $topic, 'replies' => $replies];
    hook('topic.replies_data.loaded', $data, $ctx);
    return $data;
}
function topic_page(): void
{
    if (!id() && id('replyid')) {
        $reply = row('app_replies', 'id', id('replyid')) ?: err('你访问的帖子可能已经删除', 404);
        go(route_url('topic', ['id' => (int)$reply['topic_id'], 'replyid' => id('replyid')]));
    }
    $t = row('app_topics', 'id', id()) ?: err('你访问的帖子可能已经删除', 404);
    $forum = forum_by_id((int)$t['forum_id']);
    if ($forum && !forum_group_allowed($forum, 'allow_view_groups')) err('无权限');
    if ($forum) remember_forum((int)$t['forum_id']);
    if (mark_viewed((int)$t['id'])) {
        q("UPDATE app_topics SET view_count=view_count+1 WHERE id=?", [(int)$t['id']]);
        $t['view_count'] = (int)$t['view_count'] + 1;
    }
    $size = max(1, (int)setting('replies_per_page', '50'));
    $replyid = id('replyid');
    $floor = id('floor');
    $reply_desc = (int)($t['reply_order'] ?? 0) === 1;
    if ($floor > 0) {
        if ($floor > (int)$t['reply_count']) $floor = (int)$t['reply_count'];
        $display_position = $reply_desc ? (int)$t['reply_count'] - $floor + 1 : $floor;
        $_GET['p'] = (string)max(1, (int)ceil($display_position / $size));
    } elseif ($replyid > 0) {
        $reply = row('app_replies', 'id', $replyid);
        if ($reply && (int)$reply['topic_id'] !== (int)$t['id']) $reply = null;
        if ($reply) {
            $position_sql = $reply_desc ? '(created_at>? OR (created_at=? AND id>=?))' : '(created_at<? OR (created_at=? AND id<=?))';
            $before = (int)q("SELECT COUNT(*) FROM app_replies WHERE topic_id=? AND $position_sql", [(int)$t['id'], (int)$reply['created_at'], (int)$reply['created_at'], $replyid])->fetchColumn();
            $_GET['p'] = (string)max(1, (int)ceil($before / $size));
        } else {
            err('你访问的帖子可能已经删除', 404);
        }
    }
    $p = max(1, (int)($_GET['p'] ?? 1));
    $off = ($p - 1) * $size;
    $page_data = topic_page_replies($t, $p, $size, $off, $reply_desc);
    $t = $page_data['topic'];
    $replies = $page_data['replies'];
    $filtered_replies = hook('topic.replies', $replies, [
        'topic' => $t,
        'page' => $p,
        'page_size' => $size,
        'reply_count' => (int)$t['reply_count'],
        'reply_order' => $reply_desc ? 1 : 0,
    ]);
    if (is_array($filtered_replies)) $replies = $filtered_replies;
    fire('topic.after_view', ['topic' => $t, 'replies' => $replies, 'page' => $p, 'page_size' => $size, 'reply_count' => (int)$t['reply_count']]);
    $topic_ops = '';
    if (uid()) $topic_ops .= quote_reply_action($t);
    $topic_ops = (string)hook('topic.actions', $topic_ops, ['topic' => $t]);
    if (can_manage_topic($t)) $topic_ops .= '<a class="icon-action icon-edit" href="' . h(route_url('topic_edit', ['id' => (int)$t['id']])) . '" title="编辑"><span>编辑</span></a>';
    $breadcrumb = '<div class="breadcrumb"><a href="' . h(route_url('home')) . '">首页</a><span>/</span><a href="' . h(route_url('forum', ['id' => (int)$forum['id']])) . '">' . h($forum['name']) . '</a></div>';
    $main = $breadcrumb . '<div class="post-topic-title"><h1 class="post-content-title">' . h($t['title']) . '</h1>' . topic_stats_html((int)$t['view_count'], (int)$t['reply_count']) . '</div><ul class="post-list topic-post-list">';
    if ($p === 1) $main .= topic_post_row($t, $t['body'], (int)$t['created_at'], $topic_ops);
    foreach ($replies as $i => $r) {
        $reply_floor = $reply_desc ? (int)$t['reply_count'] - $off - $i : $off + $i + 1;
        if (trim((string)$r['body']) === '') continue;
        $reply_ops = uid() ? quote_reply_action($r, $reply_floor) : '';
        if (can_manage_reply($r)) $reply_ops .= '<a class="icon-action icon-edit" href="' . h(route_url('reply_edit', ['id' => (int)$r['id']])) . '" title="编辑"><span>编辑</span></a>';
        $main .= topic_post_row($r, $r['body'], (int)$r['created_at'], $reply_ops, '', '', $floor > 0 ? $reply_floor === $floor : (int)$r['id'] === $replyid, ['reply_position' => $reply_floor]);
    }
    if (!$replies && (int)$t['reply_count'] === 0) $main .= '<li class="empty-state">暂无回复</li>';
    $pagination = paginate((int)$t['reply_count'], $p, $size, route_url('topic', ['id' => (int)$t['id']]), false);
    if ($pagination !== '') $main .= '</ul><div class="pagination-bar">' . $pagination . '</div>';
    else $main .= '</ul>';
    $can_reply_forum = forum_group_allowed($forum, 'allow_reply_groups');
    $reply_status = uid() ? (can_speak() ? ($can_reply_forum ? '说两句' : '无回帖权限') : '禁止发言') : '登录后回复';
    $help = '<span class="reply-status">' . $reply_status . '</span>';
    $main .= '<div class="reply-panel" id="reply"><div class="reply-panel-head"><h3>发表回复</h3>' . $help . '</div>';
    if (can_speak() && $can_reply_forum) {
        $reply_form_extra = (string)hook('reply.form_extra', '', ['topic' => $t, 'editing' => false]);
        $main .= '<form class="ajax-reply-form" method="post" action="' . h(route_url('reply_edit')) . '">' . form_token() . '<input type="hidden" name="topic_id" value="' . (int)$t['id'] . '">' . textarea('内容', 'body', '', true) . $reply_form_extra . '<button type="submit" data-loading-text="正在回复">回复</button></form>';
    } elseif (!uid()) {
        $main .= '<div class="reply-login-box"><a href="' . h(route_url('login')) . '">登录后回复</a></div>';
    } elseif (!$can_reply_forum) {
        $main .= '<div class="reply-login-box disabled">当前用户组无回帖权限</div>';
    } else {
        $main .= '<div class="reply-login-box disabled">当前用户禁止发言</div>';
    }
    $main .= '</div>';
    page($t['title'] . ' - ' . $forum['name'], shell_html($main, sidebar_stack_html([sidebar_user_card_html(null, true), quick_forums_html()])), page_seo('topic', ['id' => (int)$t['id']], (string)$t['body']));
}
function topic_edit_page(): void
{
    need_speak();
    $topic_id = id();
    $editing = $topic_id > 0;
    $t = ['id' => 0, 'forum_id' => id('fid') ?: default_post_forum_id(), 'title' => '', 'body' => '', 'user_id' => uid()];
    if ($editing) {
        $t = row('app_topics', 'id', $topic_id) ?: err('主题不存在');
        if (!can_manage_topic($t)) err('无权限');
    }
    if (is_post_request()) go(route_url('topic', ['id' => save_topic()]));
    $title = $editing ? '编辑主题' : '发表主题';
    $topic_ops = '';
    if ($editing && can_manage()) {
        $style = preg_match('/#[0-9a-fA-F]{6}/', (string)($t['highlight_style'] ?? ''), $m) ? $m[0] : '';
        $colors = ['#d94b4b', '#d97706', '#16a34a', '#2563eb', '#7c3aed'];
        $swatches = '<div class="topic-color-swatches">';
        foreach ($colors as $color) $swatches .= '<button class="topic-color-swatch' . ($style === $color ? ' active' : '') . '" type="button" data-topic-color="' . h($color) . '" style="background:' . h($color) . '" aria-label="' . h($color) . '"></button>';
        $swatches .= '<button class="topic-color-swatch topic-color-clear' . ($style === '' ? ' active' : '') . '" type="button" data-topic-color="" aria-label="取消高亮"></button>';
        $swatches .= '</div>';
        $topic_ops = '<label class="grid topic-action-field"><span>操作</span><select name="topic_action" data-topic-action><option value="">不操作</option><option value="delete">删除</option><option value="pin">置顶</option><option value="unpin">取消置顶</option><option value="highlight">高亮</option><option value="mute_author">禁言作者</option></select></label><label class="grid topic-highlight-field is-hidden" data-topic-highlight-wrap><span>颜色</span><input type="hidden" name="highlight_style" value="' . h($style) . '" data-topic-highlight-value>' . $swatches . '</label>';
    }
    $reply_order = $editing ? select_input('回帖排序', 'reply_order', (string)(int)($t['reply_order'] ?? 0), ['0' => '发帖时间顺序', '1' => '发帖时间倒序']) : '';
    $attachments = (string)hook('attachment.uploader', '', ['muted' => true]);
    $form_extra = (string)hook('topic.form_extra', '', ['topic' => $t, 'editing' => $editing]);
    $form_sidebar = (string)hook('topic.form_sidebar', '', ['topic' => $t, 'editing' => $editing]);
    $topic_loading_text = $editing ? '正在保存' : '正在发帖';
    page($title, shell_html('<div class="form-panel topic-form-panel"><h2>' . $title . '</h2><form method="post">' . form_token() . '<input type="hidden" name="id" value="' . (int)$t['id'] . '">' . select_forum((int)$t['forum_id']) . input('标题', 'title', $t['title'], 'text', true) . textarea('内容', 'body', $t['body'], true) . $attachments . $reply_order . $form_extra . $topic_ops . '<div class="topic-form-actions"><button type="submit" data-loading-text="' . h($topic_loading_text) . '">保存</button></div></form></div>', sidebar_stack_html(array_filter([sidebar_user_card_html(), $form_sidebar], 'strlen'))));
}
function reply_edit_page(): void
{
    need_speak();
    $reply_id = id();
    $editing = $reply_id > 0;
    $r = ['id' => 0, 'topic_id' => id('topic_id'), 'body' => '', 'user_id' => uid()];
    if ($editing) {
        $r = row('app_replies', 'id', $reply_id) ?: err('回复不存在');
        if (!can_manage_reply($r)) err('无权限');
        if (is_post_request() && ($_POST['do'] ?? '') === 'mute_author') {
            if (!can_manage()) err('无权限');
            if ((int)$r['user_id'] === 1) err('不能操作超级管理员');
            q("UPDATE app_users SET is_muted=1 WHERE id=?", [(int)$r['user_id']]);
            go(route_url('topic', ['id' => (int)$r['topic_id'], 'replyid' => (int)$r['id']]));
        }
        if (trim((string)$r['body']) === '') err('回复已删除，无法编辑');
    }
    if (is_post_request()) {
        $saved = save_reply();
        if (!empty($saved['redirect'])) go($saved['redirect']);
        if (ajax_request() && $editing) go(route_url('topic', ['id' => $saved['topic_id'], 'replyid' => $saved['reply_id']]));
        if (ajax_request()) {
            $row = row('app_replies', 'id', $saved['reply_id']) ?: err('回复不存在');
            $row = attach_users([$row])[0];
            $topic = row('app_topics', 'id', $saved['topic_id']) ?: ['view_count' => 0, 'reply_count' => 0, 'reply_order' => 0];
            $floor = (int)$topic['reply_count'];
            $ops = quote_reply_action($row, $floor);
            if (can_manage_reply($row)) $ops .= '<a class="icon-action icon-edit" href="' . h(route_url('reply_edit', ['id' => (int)$row['id']])) . '" title="编辑"><span>编辑</span></a>';
            if ((int)($topic['reply_order'] ?? 0) === 1) go(route_url('topic', ['id' => $saved['topic_id'], 'replyid' => $saved['reply_id']]));
            json_response(['ok' => 1, 'html' => topic_post_row($row, $row['body'], (int)$row['created_at'], $ops, '', '', false, ['reply_position' => $floor]), 'stats_html' => topic_stats_html((int)$topic['view_count'], (int)$topic['reply_count'])]);
        }
        go(route_url('topic', ['id' => $saved['topic_id'], 'replyid' => $saved['reply_id']]));
    }
    $ops = (int)$r['id'] > 0 ? '<span class="reply-edit-ops">' . (can_manage() ? post_action_form(route_url('reply_edit'), '禁言作者', ['id' => (int)$r['id'], 'do' => 'mute_author'], 'reply-mute-link', '确定禁言作者？') : '') . post_action_form(route_url('delete'), '删除', ['type' => 'replies', 'id' => (int)$r['id'], 'back' => 'topic', 'tid' => (int)$r['topic_id']], 'reply-delete-link', '确定删除？') . '</span>' : '';
    $reply_form_extra = (string)hook('reply.form_extra', '', ['reply' => $r, 'editing' => (int)$r['id'] > 0]);
    page('编辑回复', form_shell('<div class="form-panel reply-edit-panel"><div class="reply-edit-head"><h2>编辑回复</h2>' . $ops . '</div><form method="post">' . form_token() . '<input type="hidden" name="id" value="' . (int)$r['id'] . '"><input type="hidden" name="topic_id" value="' . (int)$r['topic_id'] . '">' . textarea('内容', 'body', $r['body'], true) . (string)hook('attachment.uploader', '', ['muted' => true]) . $reply_form_extra . '<button type="submit" data-loading-text="正在保存">保存</button></form></div>'));
}
function admin_tabs(string $tab): string
{
    $items = [];
    foreach (['settings' => '设置', 'forums' => '版块', 'groups' => '用户组', 'plugins' => '插件'] as $key => $label) {
        $items[$key] = ['label' => $label, 'href' => admin_url(['tab' => $key])];
    }
    $hook_items = hook('admin.tabs', $items, ['active' => $tab]);
    return tab_bar_html(is_array($hook_items) ? $hook_items : $items, $tab, 'admin-tabs');
}
function admin_layout(string $tab, string $body): string
{
    return shell_html(admin_tabs($tab) . $body, sidebar_stack_html([sidebar_user_card_html()], ['is_admin' => true, 'admin_tab' => $tab]));
}
function admin_settings_handle_post(): never
{
    if ((string)($_POST['debug_log_action'] ?? '') === 'clear') {
        if (!is_dir(dirname(DEBUG_LOG_FILE))) mkdir(dirname(DEBUG_LOG_FILE), 0755, true);
        file_put_contents(DEBUG_LOG_FILE, '', LOCK_EX);
        set_flash('Debug日志已清空');
        go(admin_url(['tab' => 'settings']));
    }
    if (isset($_POST['clear_opcache'])) {
        clear_opcache_cache();
        set_flash('OPcache已清理');
        go(admin_url(['tab' => 'settings']));
    }
    save_settings();
    go(admin_url(['tab' => 'settings']));
}
function admin_settings_html(): string
{
    $notice_state = Setup::update_state_data();
    $pending_notice = is_array($notice_state['update_notice'] ?? null) ? $notice_state['update_notice'] : [];
    $notice_sha = (string)($pending_notice['sha'] ?? ($notice_state['update_notice_sent_sha'] ?? ''));
    Setup::deliver_update_notice();
    $settings = settings_cache();
    $fields = [
        'site_name' => ['label' => '网站名', 'required' => true],
        'site_name_title' => ['label' => '网站名title', 'help' => '为空时使用网站名。'],
        'site_base_url' => ['label' => '网站固定地址', 'type' => 'url', 'help' => '填写以 https:// 开头的网站域名。'],
        'site_keywords' => ['label' => '关键字'],
        'site_description' => ['label' => '网站介绍', 'type' => 'textarea'],
        'pinned_topic_ids' => ['label' => '置顶主题ID'],
        'pc_nav_forum_count' => ['label' => 'PC顶部版块数量', 'type' => 'number', 'min' => 0, 'max' => 20, 'help' => 'PC端顶部默认展示的版块数量，默认6个；设为0仅显示“全部版块”。'],
        'topics_per_page' => ['label' => '列表单页数量', 'type' => 'number', 'min' => 1, 'max' => 200],
        'replies_per_page' => ['label' => '回帖单页数量', 'type' => 'number', 'min' => 1, 'max' => 200],
        'max_pagination_pages' => ['label' => '最大分页数', 'type' => 'number', 'min' => 1, 'max' => 1000, 'help' => '限制除主题回帖外的所有分页，默认50。'],
        'search_min_chars' => ['label' => '搜索最小字符数', 'type' => 'number', 'min' => 1, 'max' => 20, 'help' => '默认2；SQLite 的1至2字符搜索使用 LIKE，3字符及以上优先使用 trigram。'],
        'pretty_url' => ['label' => '是否开启rewrite', 'type' => 'checkbox'],
        'site_closed' => ['label' => '是否关闭站点进行维护', 'type' => 'checkbox'],
        'debug_mode' => ['label' => 'Debug模式', 'type' => 'checkbox'],
        'ignore_ssl_errors' => ['label' => '忽略 SSL 证书错误', 'type' => 'checkbox', 'help' => '警示篡改风险'],
        'allow_register' => ['label' => '是否允许注册', 'type' => 'checkbox'],
        'default_group_id' => ['label' => '新用户默认用户组', 'type' => 'select', 'options' => array_column(groups_cache(), 'name', 'id')],
        'post_interval_seconds' => ['label' => '发帖/回复间隔（秒）', 'type' => 'number', 'min' => 0, 'max' => 3600, 'help' => '发帖/回复间隔设置为 0 可关闭限制，默认 5 秒一次。'],
    ];
    $tools = '<div class="settings-tool-card"><div><strong>清理OPcache</strong><span>刷新已编译脚本缓存，适合代码更新后手动触发。</span></div>' . post_action_form(admin_url(['tab' => 'settings']), '清理', ['clear_opcache' => '1'], 'settings-tool-action') . '</div>';
    if ((string)($settings['debug_mode'] ?? '0') === '1') {
        $tools .= '<div class="settings-tool-card"><div><strong>Debug日志</strong><span>' . h(DEBUG_LOG_FILE) . '</span></div><div class="settings-tool-actions">' . post_action_form(admin_url(['tab' => 'settings']), '清空', ['debug_log_action' => 'clear'], 'settings-tool-action', '确定清空Debug日志？') . '<a class="settings-tool-action" href="' . h(admin_url(['tab' => 'settings', 'debug_log' => 'view'])) . '" target="_blank">查看</a></div></div>';
    }
    $update_state = is_file(UPDATE_STATE_FILE) ? json_decode((string)file_get_contents(UPDATE_STATE_FILE), true) : [];
    $update_sha = is_array($update_state) ? (string)($update_state['sha'] ?? '') : '';
    $update_time = is_array($update_state) ? (string)($update_state['updated_at'] ?? '') : '';
    $update_meta = $update_sha !== '' ? '当前版本 ' . substr($update_sha, 0, 12) . ($update_time !== '' ? ' / ' . $update_time : '') : '尚无在线升级记录';
    $update_action = is_file(APP_DIR . '/optional/Setup.php')
        ? '<a class="settings-tool-action" href="' . h(route_url('update')) . '">升级</a>'
        : '<button class="settings-tool-action" type="button" disabled>升级</button>';
    $update_dot = preg_match('/^[a-f0-9]{64}$/', $notice_sha) === 1 ? '<i class="settings-update-dot" title="发现新版本" aria-label="发现新版本"></i>' : '';
    $tools .= '<div class="settings-tool-card"><div><strong class="settings-tool-title" data-update-tool-title>系统升级' . $update_dot . '</strong><span>' . h($update_meta) . '</span></div>' . $update_action . '</div>';
    return '<span hidden data-settings-update-check-url="' . h(route_url('update', ['notice_check' => 1])) . '"></span><div class="form-panel settings-form"><form method="post">' . form_token() . render_form_fields($fields, $settings) . '<div class="row settings-actions"><button type="submit">保存</button></div></form><div class="settings-tool-grid">' . $tools . '</div></div>';
}
function admin_groups_html(): string
{
    $html = '<table class="list admin-bulk-list"><tr><th>名称</th><th>用户和内容管理</th><th>后台管理</th><th><a class="admin-head-add" href="' . h(admin_url(['do' => 'edit', 'type' => 'group', 'id' => 0])) . '">添加</a></th></tr>';
    foreach (groups_cache() as $group) {
        $html .= '<tr><td><strong class="admin-name">' . h($group['name']) . '</strong></td><td>' . admin_flag((int)($group['allow_manage'] ?? 0)) . '</td><td>' . admin_flag((int)($group['allow_admin'] ?? 0)) . '</td><td class="ops"><a href="' . h(admin_url(['do' => 'edit', 'type' => 'group', 'id' => (int)$group['id']])) . '">编辑</a>' . post_action_form(admin_url(['do' => 'delete']), '删除', ['type' => 'groups', 'id' => (int)$group['id'], 'tab' => 'groups'], 'danger', '确定删除？') . '</td></tr>';
    }
    return $html . '</table>';
}
function admin_forums_html(): string
{
    $html = '<table class="list admin-bulk-list"><tr><th>名称</th><th>排序</th><th>权限</th><th><a class="admin-head-add" href="' . h(admin_url(['do' => 'edit', 'type' => 'forum', 'id' => 0])) . '">添加</a></th></tr>';
    foreach (forums_cache() as $forum) {
        $permissions = [];
        foreach (['allow_view_groups' => '浏览', 'allow_post_groups' => '发帖', 'allow_reply_groups' => '回帖'] as $field => $label) {
            $count = count(forum_group_ids($forum, $field));
            $permissions[] = $label . ':' . ($count ? $count . '组' : '不限');
        }
        $html .= '<tr><td><strong class="admin-name">' . h($forum['name']) . '</strong></td><td><span class="admin-group-pill">' . (int)$forum['sort'] . '</span></td><td>' . h(implode(' / ', $permissions)) . '</td><td class="ops"><a href="' . h(admin_url(['do' => 'edit', 'type' => 'forum', 'id' => (int)$forum['id']])) . '">编辑</a>' . post_action_form(admin_url(['do' => 'delete']), '删除', ['type' => 'forums', 'id' => (int)$forum['id'], 'tab' => 'forums'], 'danger', '确定删除？') . '</td></tr>';
    }
    return $html . '</table>';
}
function admin_plugins_html(): string
{
    $view = (string)($_GET['view'] ?? '');
    if ($view === '') return Plugin::admin_plugins_page_html();
    if ($view === 'market') return Plugin::plugin_market_page_html();
    if ($view === 'cron') return Plugin::admin_plugins_cron_logs_page_html();
    $html = hook('admin.plugins.view', null, ['view' => $view, 'with_tabs' => true]);
    if (!is_string($html)) err('你访问的页面不存在', 404);
    return $html;
}
function admin_page(): void
{
    need_admin();
    $tab = (string)($_GET['tab'] ?? 'settings');
    if ($tab === 'settings' && (string)($_GET['debug_log'] ?? '') === 'view') {
        header('Content-Type: text/plain; charset=utf-8');
        echo is_file(DEBUG_LOG_FILE) ? (string)file_get_contents(DEBUG_LOG_FILE) : '';
        exit;
    }
    if ($tab === 'plugins' && is_post_request()) Plugin::admin_plugins_handle_post();
    if ($tab === 'plugins' && (string)($_GET['plugin_action'] ?? '') === 'enable_uploaded') Plugin::plugin_enable_uploaded_page();
    if ($tab === 'settings' && is_post_request()) admin_settings_handle_post();
    $html = match ($tab) {
        'settings' => admin_settings_html(),
        'groups' => admin_groups_html(),
        'forums' => admin_forums_html(),
        'plugins' => admin_plugins_html(),
        default => Plugin::admin_plugin_tab_html($tab),
    };
    if ($html === null) err('你访问的页面不存在', 404);
    page('后台', admin_layout($tab, $html));
}
function admin_edit_page(): void
{
    need_admin();
    $type = $_GET['type'] ?? $_POST['type'] ?? '';
    if (is_post_request()) {
        if ($type === 'group') save_group();
        elseif ($type === 'forum') save_forum();
        else err('参数错误');
        go(admin_url(['tab' => $type . 's']));
    }
    if ($type === 'group') {
        $g = id() ? (group_by_id(id()) ?: err('用户组不存在')) : ['id' => 0, 'name' => '', 'allow_manage' => 0, 'allow_admin' => 0];
        $tab = 'groups';
        $body = input('名称', 'name', $g['name'], 'text', true) . checkbox('允许用户和内容管理', 'allow_manage', (bool)(int)($g['allow_manage'] ?? 0)) . checkbox('允许后台管理', 'allow_admin', (bool)(int)($g['allow_admin'] ?? 0));
    } elseif ($type === 'forum') {
        $f = id() ? forum_by_id(id()) : ['id' => 0, 'name' => '', 'description' => '', 'sort' => 0, 'allow_view_groups' => '', 'allow_post_groups' => '', 'allow_reply_groups' => ''];
        if (!$f) err('版块不存在');
        $tab = 'forums';
        $body = input('名称', 'name', $f['name'], 'text', true) . number_input('排序', 'sort', $f['sort']) . textarea('描述', 'description', $f['description']) . forum_group_select_options($f, 'allow_view_groups', '允许浏览用户组') . forum_group_select_options($f, 'allow_post_groups', '允许发帖用户组') . forum_group_select_options($f, 'allow_reply_groups', '允许回帖用户组');
    } else err('参数错误');
    page('编辑', admin_layout($tab, '<div class="form-panel"><h2>编辑</h2><form method="post">' . form_token() . '<input type="hidden" name="type" value="' . h($type) . '"><input type="hidden" name="id" value="' . id() . '">' . $body . '<button>保存</button></form></div>'));
}
function robots_page(): void
{
    header('Content-Type: text/plain; charset=utf-8');
    echo (string)hook('robots.txt', "User-agent: *\nDisallow:\n", []);
    exit;
}
function favicon_page(): void
{
    header('Location: ' . asset_url('app/assets/index.svg'), true, 302);
    exit;
}
function form_error_route(): void
{
    $raw = base64_decode((string)($_COOKIE['__form_error'] ?? ''), true);
    $data = is_string($raw) ? json_decode($raw, true) : [];
    app_cookie('__form_error', '', time() - 3600);
    error_page('操作失败', trim((string)(is_array($data) ? ($data['message'] ?? '') : '') ?: '操作失败'));
}
function logout_route(): void
{
    require_post();
    clear_auth_cookie();
    go(route_url('home'));
}
function delete_route(): void
{
    require_post(); need_login();
    $type = (string)($_POST['type'] ?? '');
    $row = deletable_post_row($type, id());
    if (!$row || !in_array($type, ['topics', 'replies'], true)) err('参数错误');
    if (($type === 'topics' && !can_manage_topic($row)) || ($type === 'replies' && !can_manage_reply($row))) err('无权限');
    del($type, id());
    if ((string)($_POST['back'] ?? '') === 'topic') go(route_url('topic', ['id' => (int)($_POST['tid'] ?? 0)]));
    go(route_url('home'));
}
function admin_route(): void
{
    $do = (string)($_GET['do'] ?? '');
    if ($do === 'edit') { admin_edit_page(); return; }
    if ($do === '') { admin_page(); return; }
    if ($do !== 'delete') err('你访问的页面不存在', 404);
    require_post(); need_admin();
    $type = ['group' => 'groups', 'groups' => 'groups', 'forum' => 'forums', 'forums' => 'forums'][$_POST['type'] ?? ''] ?? '';
    if (!in_array($type, ['groups', 'forums'], true)) err('参数错误');
    if (!can_admin_delete($type, id())) err('无权限');
    del($type, id());
    go(admin_url(['tab' => $type]));
}
function core_routes(): array
{
    return [
        'home' => 'home_page',
        'robots.txt' => 'robots_page',
        'favicon.ico' => 'favicon_page',
        'apple-touch-icon.png' => 'favicon_page',
        'apple-touch-icon-precomposed.png' => 'favicon_page',
        'search' => 'search_page',
        'forum' => 'forum_page',
        'topic' => 'topic_page',
        'user' => 'user_page',
        'login' => 'login_page',
        'logout' => 'logout_route',
        'register' => 'register_page',
        'form_error' => 'form_error_route',
        'profile' => 'profile_page',
        'notify' => 'user_notify_page',
        'topic_edit' => 'topic_edit_page',
        'reply_edit' => 'reply_edit_page',
        'delete' => 'delete_route',
        'migrate' => [Setup::class, 'migrate_page'],
        'admin' => 'admin_route',
        'cron' => [Cron::class, 'cron_route'],
        'opcache_refresh' => 'opcache_refresh_route',
        'plugin_market_install' => [Plugin::class, 'plugin_market_install_page'],
        'plugin_market_share' => [Plugin::class, 'plugin_market_share_page'],
        'plugin_download' => [Plugin::class, 'plugin_download_page'],
    ];
}
if (PHP_SAPI === 'cli' && (string)($_SERVER['argv'][1] ?? '') === 'cron') {
    $_GET['a'] = 'cron';
    $_SERVER['REQUEST_METHOD'] = 'GET';
}
parse_path_route();
$setup_action = (string)($_GET['a'] ?? '');
if ($setup_action === 'install') {
    Setup::setup_install_run();
}
if ($setup_action === 'update') {
    Setup::setup_update_run();
}
if (!db_schema_ready()) err('欢迎使用，请先进行数据初始化安装', 200, 'simple', false, index_url(['a' => 'install']));
if (setting('plugin_sync_pending', '0') === '1') {
    Plugin::plugin_registry_sync();
    Plugin::plugin_assets_rebuild();
    save_settings_values(['plugin_sync_pending' => '0']);
}
check();
need_site_access();
fire('app.boot');
try {
    if (($_GET['__route_not_found'] ?? '') === '1') {
        err(($_GET['__route_not_found_kind'] ?? '') === 'topic' ? '你访问的帖子可能已经删除' : '你访问的页面不存在', 404);
    }
    $route = (string)($_GET['a'] ?? 'home');
    $handler = core_routes()[$route] ?? null;
    if ($handler !== null) $handler();
    elseif (!plugin_route($route)) err('你访问的页面不存在', 404);
} catch (Throwable $e) {
    debug_log_write('未捕获异常', $e);
    if (uid() === 1) {
        $message = exception_detail($e);
    } elseif (database_error($e)) {
        $message = database_error_message();
    } else {
        $message = '操作失败';
    }
    err($message);
}
