'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_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); $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 { topic_list_rows_register($rows); $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', 'username_min_length' => '2', 'username_max_length' => '20', 'email_min_length' => '7', 'email_max_length' => '150', 'bio_min_length' => '0', 'bio_max_length' => '10000', 'search_min_length' => '2', 'search_max_length' => '80', 'title_min_length' => '1', 'title_max_length' => (string)DB_STRING_MAX_LENGTH, 'topic_body_min_length' => '1', 'topic_body_max_length' => (string)DB_TEXT_MAX_LENGTH, 'reply_body_min_length' => '1', 'reply_body_max_length' => (string)DB_TEXT_MAX_LENGTH, 'excerpt_length' => '200', 'post_interval_seconds' => '5', ]; } 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 length_limit(string $type, string $bound): int { $hard_max = in_array($type, ['username', 'email', 'search', 'title'], true) ? DB_STRING_MAX_LENGTH : DB_TEXT_MAX_LENGTH; $key = $bound === 'value' ? $type . '_length' : $type . '_' . $bound . '_length'; $value = min($hard_max, max(0, (int)setting($key, '0'))); if ($bound === 'max') $value = max($value, min($hard_max, max(0, (int)setting($type . '_min_length', '0')))); return $value; } function excerpt_length(): int { return length_limit('excerpt', 'value'); } function require_length(string $value, int $minimum, string $label): void { if ($minimum > 0 && preg_match_all('/./us', $value) < $minimum) err($label . '至少' . $minimum . '个字符'); } 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 = (string)($manifest[$type] ?? ''); $file = 'app/assets/plugins.' . $type; if ($type === 'css') return ''; return ''; } 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_settings_tabs' => ['hook' => 'profile.settings_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' => '顶部菜单'], 'top_actions' => ['hook' => 'top.bar.actions', '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; if ($name === 'topic.index_data.loaded') topic_list_rows_mark($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 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 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_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 ? '' . (int)$count . '' : ''; } function content_excerpt(string $body, ?int $max = null): string { $body = trim(preg_replace('/\s+/u', ' ', $body) ?? ''); return cut($body, $max ?? excerpt_length()); } 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 = null): 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 notification_box(): string { $box = (string)($_GET['box'] ?? 'all'); return in_array($box, ['all', 'inbox', 'outbox'], true) ? $box : 'all'; } function notifications_list(int $uid, int $limit, int $offset = 0, string $box = 'all'): array { $where = $box === 'outbox' ? "sender_id=? AND kind='direct'" : ($box === 'inbox' ? "recipient_id=? AND kind='direct'" : 'recipient_id=?'); $params = [$uid, $limit, $offset]; $rows = q("SELECT * FROM app_notifications WHERE {$where} ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?", $params)->fetchAll(); $user_ids = array_merge(array_column($rows, 'sender_id'), array_column($rows, 'recipient_id')); $users = rows_by_ids('app_users', $user_ids, 'id,username,avatar_style,avatar_seed'); foreach ($rows as &$row) { $display_id = (int)($row['sender_id'] ?? 0) === $uid ? (int)($row['recipient_id'] ?? 0) : (int)($row['sender_id'] ?? 0); $u = $users[$display_id] ?? 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, string $box = 'all'): int { if ($box === 'all') return (int)val('SELECT COUNT(*) FROM app_notifications WHERE recipient_id=?', [$uid]); return (int)val("SELECT COUNT(*) FROM app_notifications WHERE " . ($box === 'outbox' ? "sender_id=? AND kind='direct'" : "recipient_id=? AND kind='direct'"), [$uid]); } function notifications_unread_total(int $uid, bool $direct_only = false): int { $m = me(); if (!$direct_only && $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" . ($direct_only ? " AND kind='direct'" : ''), [$uid]); } function mark_notifications_read(int $uid, int $unread, bool $direct_only = false): void { if ($unread <= 0) return; q("UPDATE app_notifications SET read_at=? WHERE recipient_id=? AND read_at=0" . ($direct_only ? " AND kind='direct'" : ''), [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 !== '') { $title_marker = "\x1ENOTIFICATION_TOPIC_TITLE\x1E"; $topic_title = ''; $source = preg_replace_callback('/《((?:[^《》]|(?R))*)》/u', static function (array $match) use (&$topic_title, $title_marker): string { $topic_title = $match[1]; return '《' . $title_marker . '》'; }, $body, 1, $count); if (is_string($source) && $count > 0) { // Render the title as one link so mentions in it cannot create nested anchors. $content_html = str_replace($title_marker, '' . h($topic_title) . '', markdown_html($source)); } else { $view_link = '查看主题'; $content_html = preg_replace('/<\/p>\s*$/u', ' ' . $view_link . '

', $content_html, 1, $paragraph_count) ?? $content_html; if ($paragraph_count < 1) $content_html .= ' ' . $view_link; } } } $kind = match ((string)($n['kind'] ?? '')) { 'mention' => '提及', 'sent' => '已发出', default => '通知' }; $unread = (int)($n['read_at'] ?? 0) === 0; $quote = notification_excerpt($body); $action = (string)($n['kind'] ?? '') === 'direct' && $sender_id > 0 ? '回复TA' : ''; $sender_title = $sender_id > 0 ? '' . h($sender_name) . '' : '' . h($sender_name) . ''; return '
  • ' . avatar_link_tag($sender_id ?: 0, $sender_name, (string)($n['sender_avatar_style'] ?? ''), '', (string)($n['sender_avatar_seed'] ?? '')) . '
    ' . $sender_title . '' . h($kind) . '' . ($unread ? '未读' : '') . '
    ' . $content_html . '
    ' . $action . '
  • '; } function admin_list_head(string $left = '', string $right = ''): string { return '
    ' . $left . '
    ' . $right . '
    '; } function user_state_tag_html(array $u): string { $tags = []; if ((int)($u['is_banned'] ?? 0) === 1) $tags[] = '禁访'; if ((int)($u['is_muted'] ?? 0) === 1) $tags[] = '禁言'; return $tags ? '' . implode('', $tags) . '' : ''; } 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 sidebar_notice_card_html(string $title, array $items): string { $html = ''; } 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_data(): array { if (array_key_exists('__sidebar_feature_links_data', $GLOBALS)) return (array)$GLOBALS['__sidebar_feature_links_data']; $links = hook('sidebar.feature_links', [], ['is_home_first_page' => true]); return $GLOBALS['__sidebar_feature_links_data'] = is_array($links) ? $links : []; } function user_menu_links_data(array $user, bool $self): array { $key = (int)($user['id'] ?? 0) . '.' . (int)$self; if (isset($GLOBALS['__user_menu_links_data'][$key])) return (array)$GLOBALS['__user_menu_links_data'][$key]; $links = hook('user.menu_links', [], ['user' => $user, 'self' => $self]); return $GLOBALS['__user_menu_links_data'][$key] = is_array($links) ? $links : []; } function sidebar_feature_links_html(array $ctx = []): string { if (empty($ctx['is_home_first_page'])) return ''; $links = sidebar_feature_links_data(); if (!is_array($links) || !$links) return ''; $html = '' : ''; } function mobile_menu_section_html(string $title, array $links): string { $html = '

    ' . h($title) . '

    '; } function top_menu_links(?array $mine = null): array { $key = (int)($mine['id'] ?? 0); if (isset($GLOBALS['__top_menu_links']) && array_key_exists($key, $GLOBALS['__top_menu_links'])) return (array)$GLOBALS['__top_menu_links'][$key]; $raw_links = hook('top.menu_links', [], ['user' => $mine]); if (!is_array($raw_links)) return $GLOBALS['__top_menu_links'][$key] = []; $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 $GLOBALS['__top_menu_links'][$key] = $links; } function top_bar_actions(?array $mine = null): array { $defaults = [ 'left' => [], 'right_before_search' => [], 'right_after_search' => [], ]; $registered = hook('top.bar.actions', $defaults, ['user' => $mine]); if (!is_array($registered)) return $defaults; foreach ($defaults as $region => $_items) { if (!isset($registered[$region]) || !is_array($registered[$region])) $registered[$region] = []; } return $registered; } function top_bar_actions_html(array $actions, string $region): string { $items = $actions[$region] ?? []; if (!is_array($items)) return ''; $html = ''; foreach ($items as $item) { if (is_string($item)) $html .= $item; elseif (is_array($item)) $html .= (string)($item['html'] ?? ''); } return $html; } function mobile_menu_content_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($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')]; foreach (user_menu_links_data($mine, true) 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 = sidebar_feature_links_data(); 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 mobile_menu_section_html('版块列表', $forum_links) . mobile_menu_section_html('我的菜单', $my_links) . $quick_section; } function mobile_menu_html(): string { return ''; } 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 '
    ' . $main . $mainpanel_extra . '
    ' . $sidebar . '
    '; } function tab_bar_html(array $items, string $active, string $class = '', string $slot = ''): string { $html = '
    '; 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 .= '' . $label . ''; } return $html . '
    '; } 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 = ''; } function sidebar_user_card_html(?array $m = null, bool $reply_button = false, int $fid = 0): string { $m = $m ?: me(); if (!$m) return ''; $is_self = uid() && (int)$m['id'] === uid(); $prefix = $is_self ? '我的' : 'TA的'; $unread = $is_self ? (int)($m['unread_notifications'] ?? 0) : 0; $links = '' . svg_icon('topic') . $prefix . '主题' . svg_icon('reply') . $prefix . '回帖'; $extra_links = user_menu_links_data($m, $is_self); 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'] ?? ''); $links .= '' . ($icon_html !== '' ? $icon_html : ($icon !== '' ? svg_icon($icon) : '')) . h($text) . ''; } if ($is_self) $links .= '' . svg_icon('notify') . $prefix . '通知' . notification_badge_html($unread) . '' . svg_icon('settings') . '个人设置' . (can_access_admin() ? '' . svg_icon('admin') . '后台面板' : ''); $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 = ''; } 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 ? '' . h($g['name']) . '' : ''; } function form_shell(string $body, ?array $m = null): string { return shell_html($body, sidebar_stack_html([sidebar_user_card_html($m)])); } 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 !== '' ? '' . h($message) . '' : h($message); echo '错误
    ' . $content . '
    '; 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 = '

    ' . h($title) . '

    ' . h($message !== '' ? $message : $title) . '

    '; 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 = ''; 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 = ''; } 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 = '' . svg_icon('pages'); $prev = 1; foreach ($nums as $i) { if ($i - $prev > 1) $h .= '…'; $h .= '' . $i . ''; $prev = $i; } return $h . ''; } 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 ''; } function hidden_inputs(array $fields): string { $html = ''; foreach ($fields as $name => $value) { if ($value === null) continue; $html .= ''; } 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_token() . hidden_inputs($fields) . '
    '; } function require_post(): void { if (!is_post_request()) err('请求方式错误'); } function svg_icon(string $name): string { static $icons = [ 'user' => '', 'id' => '', 'reply' => '', 'notify' => '', 'forum' => '', 'topic' => '', 'view' => '', 'settings' => '', 'admin' => '', 'pages' => '', 'upload' => '', ]; return isset($icons[$name]) ? '' : ''; } 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 '' . h($name) . ''; } 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 '' . $avatar . ''; } 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 ? append_url_query(app_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, '@' . $m[1] . ' #' . (int)$m[2] . ''); }, $escaped_text) ?? $escaped_text; $escaped_text = preg_replace_callback('/(? $username])) . '">@' . $m[1] . ''; }, $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 = '

    ' . str_replace("\n", '
    ', content_special_links_html(h($text), $topic_id)) . '

    '; 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 = '
    ' . avatar_tag($uid, $name, $style, '', $seed) . '头像当前头像
    '; } function html_anchor(string $kind, int $id): string { return ''; } 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 ? '

    ' . h($title) . '

    ' . $stats . '
    ' : ''; $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 ? '#' . $floor . '' : '主楼'; $ops_left = ''; $ops_menu = ''; foreach ((array)hook('post.ops_actions', [], ['row' => $row, 'is_reply' => $is_reply, 'topic_id' => $topic_id, 'floor' => $floor]) as $ops_item) { $ops_item_html = is_array($ops_item) ? (string)($ops_item['html'] ?? '') : (string)$ops_item; if ($ops_item_html === '') continue; if (is_array($ops_item) && ($ops_item['placement'] ?? 'menu') === 'left') $ops_left .= $ops_item_html; else $ops_menu .= $ops_item_html; } if ($is_reply ? can_manage_reply($row) : can_manage_topic($row)) { $edit_url = route_url($is_reply ? 'reply_edit' : 'topic_edit', ['id' => (int)$row['id']]); $ops_menu .= '编辑'; } if ($topic_id > 0) { $copy_url = route_url('topic', ['id' => $topic_id] + ($floor > 0 ? ['floor' => $floor] : [])); $ops_menu .= ''; } $ops_toggle = $ops_menu !== '' ? '' : ''; $ops_html = $ops !== '' || $ops_left !== '' || $floor_html !== '' || $ops_toggle !== '' ? '
    ' . $ops . $ops_left . $floor_html . $ops_toggle . '
    ' : ''; $row_slots = $is_reply ? 'reply.after_render reply.content_after' : 'topic.after_render topic.content_after'; $uid_html = ((int)($row['user_id'] ?? 0) > 0 && empty($row['plugin_anonymous_posting_masked']) && (string)($row['username'] ?? '') !== '匿名') ? '' . (int)$row['user_id'] . '' : ''; $tags_html = topic_user_group_html($row) . user_state_tag_html($row) . $uid_html; $body_html = (string)hook($is_reply ? 'reply.content_html' : 'topic.content_html', markdown_html($body, 0, $topic_id), ['row' => $row, 'body' => $body, 'topic_id' => $topic_id] + $ctx); $html = '
  • ' . $title_html . '
    ' . $avatar . '
    ' . $body_html . $ops_html . '
  • '; $html = (string)hook($is_reply ? 'reply.after_render' : 'topic.after_render', $html, ['row' => $row, 'body' => $body] + $ctx); $anchor = html_anchor($is_reply ? 'reply' : 'post', (int)($row['id'] ?? 0)); if (str_ends_with($html, '')) { $html = substr($html, 0, -5) . $anchor . ''; } else { $end = strrpos($html, ''); $html = $end === false ? $html . $anchor : substr($html, 0, $end) . $anchor . substr($html, $end); } $content_after = (string)hook($is_reply ? 'reply.content_after' : 'topic.content_after', '', ['row' => $row, 'body' => $body, 'topic_id' => $topic_id] + $ctx); if ($content_after === '') return $html; $end = strrpos($html, '' . $anchor); 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 '回复'; } 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 search_min_chars(): int { return length_limit('search', 'min'); } function search_char_count(string $query): int { $count = preg_match_all('/./us', trim($query)); return $count === false ? 0 : $count; } 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 content_search_condition(string $query, string $field = 'title'): array { $field = in_array($field, ['title', 'body', 'reply'], true) ? $field : 'title'; $column = $field === 'title' ? 'title' : 'body'; $fallback = [$column . (db_driver() === 'pgsql' ? " ILIKE ? ESCAPE '!'" : " LIKE ? ESCAPE '!'"), [search_like_pattern($query)]]; $condition = hook('search.condition', $fallback, ['query' => trim($query), 'field' => $field, 'column' => $column]); return is_array($condition) && isset($condition[0], $condition[1]) && is_string($condition[0]) && is_array($condition[1]) ? $condition : $fallback; } function topic_fts_sync(int $id, string $title, string $body): void { fire('search.topic_sync', ['id' => $id, 'title' => $title, 'body' => $body]); } function topic_fts_delete(int $id): void { fire('search.topic_delete', ['id' => $id]); } function reply_fts_sync(int $id, string $body): void { fire('search.reply_sync', ['id' => $id, 'body' => $body]); } function reply_fts_delete(int $id): void { fire('search.reply_delete', ['id' => $id]); } function search_index_available(): bool { return (bool)hook('search.index_available', false, ['driver' => db_driver()]); } function search_index_rebuild(string $type = 'all', int $start_id = 1): int { $type = in_array($type, ['topics', 'replies'], true) ? $type : 'all'; return max(0, (int)hook('search.rebuild', 0, ['type' => $type, 'start_id' => max(1, $start_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'])), ]; } return attach_topic_list_users($rows); } function topic_list_row(array $t, string $sort): string { topic_list_preload_flush(); $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 !== '' ? '
    ' . h($reply_excerpt) . '
    ' : ''; $user_link = '' . svg_icon('user') . h($t['username']) . ''; $html = '
  • ' . avatar_link_tag((int)$t['user_id'], (string)$t['username'], (string)($t['avatar_style'] ?? ''), '', (string)($t['avatar_seed'] ?? '')) . '
    ' . $reply_excerpt_html . '
  • '; 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 = '' . svg_icon('user') . h($t['username']) . ''; $forum_link = $has_forum ? '' . h($forum['name']) . '' : ''; $last_reply_username = (string)($t['last_reply_username'] ?? ''); $last_reply_user = $last_reply_username !== '' ? '' . svg_icon('user') . h($last_reply_username) . '' : ''; $time_meta = '' . human_time($time) . ''; $forum_meta = $has_forum ? '' . svg_icon('forum') . $forum_link . '' : ''; $meta = '' . $user_link . '' . ($sort === 'post' ? $time_meta : '') . $forum_meta . '' . svg_icon('reply') . (int)$t['reply_count'] . '' . $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 !== '' ? '
    ' . h($reply_excerpt) . '
    ' : ''; $badges = ((int)($t['is_pinned'] ?? 0) ? '置顶' : ''); $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 ? '' : ''; $html = '
  • ' . avatar_link_tag((int)$t['user_id'], (string)$t['username'], (string)($t['avatar_style'] ?? ''), '', (string)($t['avatar_seed'] ?? '')) . '
    ' . $badges . '' . h($t['title']) . '' . $title_suffix . $pages . '
    ' . $reply_excerpt_html . '
    ' . $forum_badge . '
  • '; return (string)hook('topic.after_render', $html, ['row' => $t, 'list' => true, 'sort' => $sort]); } function topic_list_rows_register(array $rows): void { if (!$rows) return; $batch = (int)($GLOBALS['__topic_list_batch'] ?? 0); if (!isset($GLOBALS['__topic_list_rows'])) $GLOBALS['__topic_list_rows'] = []; foreach ($rows as $row) { $id = is_array($row) ? (int)($row['id'] ?? 0) : 0; if ($id > 0 && !isset($GLOBALS['__topic_list_rows'][$id])) $GLOBALS['__topic_list_rows'][$id] = [$batch, $row]; } } function topic_list_rows_mark(mixed $value): void { if (!is_array($value) || empty($value['rows']) || !is_array($value['rows'])) return; $GLOBALS['__topic_list_batch'] = (int)($GLOBALS['__topic_list_batch'] ?? 0) + 1; foreach ($value['rows'] as $row) { $id = is_array($row) ? (int)($row['id'] ?? 0) : 0; if ($id > 0) $GLOBALS['__topic_list_preloaded_ids'][$id] = 1; } } function topic_list_preload_flush(): void { $pending = $GLOBALS['__topic_list_rows'] ?? null; if (!$pending) return; $GLOBALS['__topic_list_rows'] = []; if (!empty($GLOBALS['__topic_list_flushing'])) return; $batch = (int)($GLOBALS['__topic_list_batch'] ?? 0); $done = $GLOBALS['__topic_list_preloaded_ids'] ?? []; $fresh = []; foreach ($pending as $id => $entry) { if ((int)$entry[0] < $batch) continue; if (isset($done[$id])) continue; $fresh[] = $entry[1]; } if (!$fresh) return; $GLOBALS['__topic_list_flushing'] = 1; try { topic_list_preload($fresh); } finally { $GLOBALS['__topic_list_flushing'] = 0; } } function topic_list_preload(array $rows): array { if (!$rows) return $rows; $loaded = hook('topic.index_data.loaded', ['rows' => $rows], ['list' => true, 'preload_only' => true]); return is_array($loaded) && is_array($loaded['rows'] ?? null) ? $loaded['rows'] : $rows; } function topic_stats_html(int $view_count, int $reply_count): string { $stats = ''; if ($view_count > 0) $stats .= '' . svg_icon('view') . $view_count . ''; if ($reply_count > 0) $stats .= '' . svg_icon('reply') . $reply_count . ''; return $stats ? '
    ' . $stats . '
    ' : ''; } function page_head_html(string $page_title, string $meta, string $head_extra = ''): string { return '' . $meta . '' . h($page_title) . '' . plugin_asset_tag('css') . $head_extra . ''; } function page_nav_html(string $site_name): string { $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' => 'notifications']) : route_url('login'); $mobile_avatar = $mine ? avatar_tag((int)$mine['id'], (string)$mine['username'], (string)($mine['avatar_style'] ?? ''), 'mobile-nav-avatar', (string)($mine['avatar_seed'] ?? '')) : svg_icon('user'); $mobile_unread = $mine_unread > 0 ? '' . $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 = '
    ' . h($site_name) . '
    ' . $left_actions . '
    ' . $more_button_html . '
    ' . $mobile_actions . '
    ' . $more_panel_html . mobile_menu_html(); } function page_footer_html(string $title, string $flash): string { $footer_html = (string)hook('page.footer', '', ['title' => $title]); $plugin_js = plugin_asset_tag('js'); return '
    ' . $footer_html . '
    ' . project_modal_html() . '' . $plugin_js . ''; } function project_modal_html(): string { return ''; } function page_template(array $data): string { $html = hook('page.template.before', null, $data); if (!is_string($html)) $html = page_head_html($data['page_title'], $data['meta'], $data['head_extra']) . page_nav_html($data['site_name']) . $data['header_html'] . '
    ' . $data['body'] . '
    ' . page_footer_html($data['title'], $data['flash']); return (string)hook('page.template', $html, $data); } 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 .= ''; if ($description !== '') $meta .= ''; if (!empty($seo['canonical'])) $meta .= ''; $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_template(compact('title', 'body', 'seo', 'settings', 'site_name', 'page_title', 'is_home', 'meta', 'head_extra', 'header_html', 'flash')); } function form_field_caption(string $label, string $help = ''): string { return '' . h($label) . ($help !== '' ? '' . h($help) . '' : '') . ''; } function length_attributes(string $name): string { $limits = match ($name) { 'username' => [length_limit('username', 'min'), length_limit('username', 'max')], 'email' => [length_limit('email', 'min'), length_limit('email', 'max')], 'bio' => [length_limit('bio', 'min'), length_limit('bio', 'max')], 'q' => [length_limit('search', 'min'), length_limit('search', 'max')], 'title' => [length_limit('title', 'min'), length_limit('title', 'max')], 'content' => [0, DB_TEXT_MAX_LENGTH], 'body' => [min(length_limit('topic_body', 'min'), length_limit('reply_body', 'min')), max(length_limit('topic_body', 'max'), length_limit('reply_body', 'max'))], default => [null, null], }; [$min, $max] = $limits; return ($min !== null && $min > 0 ? ' minlength="' . $min . '"' : '') . ($max !== null ? ' maxlength="' . $max . '"' : ''); } function input(string $label, string $name, mixed $value = '', string $type = 'text', bool $required = false, string $help = '', string $class = ''): string { return ''; } function textarea(string $label, string $name, mixed $value = '', bool $required = false, string $help = '', string $class = ''): string { return ''; } function checkbox(string $label, string $name, bool $checked = false, string $help = '', string $class = ''): string { return ''; } 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 ''; } function select_input(string $label, string $name, mixed $value, array $options, string $help = '', string $class = ''): string { $html = ''; } 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 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 require_valid_username(string $username): void { if ($username === '') err('用户名不能为空'); $result = preg_match('/[\s\p{Z}\p{C}]/u', $username); if ($result === false) err('用户名包含非法字符'); if ($result === 1) err('用户名不能包含空白或不可见字符'); } 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操作次数已达上限'); $is_registration = !$admin && !$user_id; $username = post('username', length_limit('username', 'max')); $email = post('email', length_limit('email', 'max')); $bio = post('bio', length_limit('bio', 'max')); $avatar_style = avatar_style(post('avatar_style', DB_STRING_MAX_LENGTH)); $avatar_seed = post('avatar_seed', DB_STRING_MAX_LENGTH); 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('用户名不能为空'); require_length($username, length_limit('username', 'min'), '用户名'); require_length($email, length_limit('email', 'min'), '邮箱地址'); require_length($bio, length_limit('bio', 'min'), '个人简介'); 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), length_limit('username', 'max')); $email = cut((string)($filtered['email'] ?? $email), length_limit('email', 'max')); $bio = cut((string)($filtered['bio'] ?? $bio), length_limit('bio', 'max')); $avatar_style = avatar_style(cut((string)($filtered['avatar_style'] ?? $avatar_style), DB_STRING_MAX_LENGTH)); $avatar_seed = cut((string)($filtered['avatar_seed'] ?? $avatar_seed), DB_STRING_MAX_LENGTH); $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 (!$old_user || (string)$old_user['username'] !== $username) require_valid_username($username); if ($is_registration && preg_match_all('/./us', $username) < length_limit('username', 'min')) err('用户名长度不能少于' . length_limit('username', 'min') . '个字符'); if ($is_registration && preg_match_all('/./us', $username) > length_limit('username', 'max')) err('用户名不能超过' . length_limit('username', 'max') . '个字符'); $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]); } } 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'] ?? '')); $body = post('content', length_limit('reply_body', 'max')); $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'] ?? '')); $quote_html = $quote !== '' ? '

    ' . h($quote) . '

    ' : ''; $notify_max_length = length_limit('reply_body', 'max'); $html = '
    ' . form_token() . $quote_html . '
    '; if (ajax_request()) { echo $html; exit; } page('通知TA', form_shell('

    通知TA

    ' . $html . '
    ', 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 = null): 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 ?? excerpt_length()); } 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 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 topic_title_color(string $style): string { return preg_match('/(?:^|;)\s*color\s*:\s*(#[0-9a-fA-F]{6})(?:\s*;|$)/', $style, $matches) ? $matches[1] : ''; } function topic_title_is_large(string $style): bool { return preg_match('/(?:^|;)\s*font-size\s*:\s*1.2em(?:\s*;|$)/i', $style) === 1; } function topic_title_style(string $color, bool $large): string { $styles = []; if (preg_match('/^#[0-9a-fA-F]{6}$/', $color) === 1) $styles[] = 'color:' . $color; if ($large) $styles[] = 'font-size:1.2em'; return implode(';', $styles); } 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', length_limit('title', 'max')); $body = post('body', length_limit('topic_body', 'max')); $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), length_limit('title', 'max')); $body = cut((string)($filtered['body'] ?? $body), length_limit('topic_body', 'max')); $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 (in_array($action, ['delete_topic', 'delete_topic_replies'], true)) { del('topics', (int)$t['id'], true); go(route_url('home')); } if (in_array($action, ['pin', 'unpin'], true)) { $pin_action = $action === 'pin' ? (string)($_POST['topic_pin_action'] ?? 'pin') : 'unpin'; if (!in_array($pin_action, ['pin', 'unpin'], true)) err('请选择置顶操作'); set_pinned_topic((int)$t['id'], $pin_action === 'pin'); go(route_url('topic', ['id' => (int)$t['id']])); } if (in_array($action, ['highlight', 'enlarge'], true)) { $current_style = (string)($t['highlight_style'] ?? ''); $color = topic_title_color($current_style); $large = topic_title_is_large($current_style); if ($action === 'highlight') { $raw_color = trim((string)($_POST['highlight_style'] ?? '')); $color = $raw_color === '' ? '' : (preg_match('/^#[0-9a-fA-F]{6}$/', $raw_color, $m) ? $m[0] : '#d94b4b'); } else { $enlarge_action = (string)($_POST['topic_enlarge_action'] ?? ''); if (!in_array($enlarge_action, ['enlarge', 'normal'], true)) err('请选择放大操作'); $large = $enlarge_action === 'enlarge'; } q("UPDATE app_topics SET highlight_style=? WHERE id=?", [topic_title_style($color, $large), (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('标题和内容不能为空'); require_length($title, length_limit('title', 'min'), '标题'); require_length($body, length_limit('topic_body', 'min'), '主题内容'); } 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('标题和内容不能为空'); require_length($title, length_limit('title', 'min'), '标题'); require_length($body, length_limit('topic_body', 'min'), '主题内容'); $author = content_create_author('topic.create_author', ['title' => $title, 'body' => $body], ['forum_id' => $fid], ['title' => length_limit('title', 'max'), 'body' => length_limit('topic_body', 'max')]); $author_id = (int)$author['user_id']; $title = $author['title']; $body = $author['body']; if ($title === '' || $body === '') err('标题和内容不能为空'); require_length($title, length_limit('title', 'min'), '标题'); require_length($body, length_limit('topic_body', 'min'), '主题内容'); $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; }); 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', length_limit('reply_body', 'max')); $filtered = hook('reply.before_save', ['body' => $body, 'topic_id' => $tid], ['id' => $reply_id]); if (is_array($filtered)) $body = cut((string)($filtered['body'] ?? $body), length_limit('reply_body', 'max')); if ($body === '') err('回复不能为空'); require_length($body, length_limit('reply_body', 'min'), '回帖内容'); 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']]; } require_length($body, length_limit('reply_body', 'min'), '回帖内容'); $author = content_create_author('reply.create_author', ['body' => $body], ['topic_id' => $tid], ['body' => length_limit('reply_body', 'max')]); $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; }); 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 content_delete_notify(array $row, bool $is_reply, int $topic_id = 0): void { $author_id = (int)($row['user_id'] ?? 0); if ($author_id <= 0 || $author_id === uid()) return; if ($is_reply) { create_notification($author_id, uid(), 'delete', '您的回帖已被删除。', max(0, $topic_id)); return; } $title = trim((string)($row['title'] ?? '')); create_notification($author_id, uid(), 'delete', '您的主题《' . ($title !== '' ? $title : '#' . (int)($row['id'] ?? 0)) . '》已被删除。'); } function app_topics_del_ready(): bool { static $ready = null; if ($ready === null) $ready = app_db_table_exists(db(), db_driver(), 'app_topics_del'); return $ready; } function floor_index_clear(): void { unset($GLOBALS['__floor_cache']); } function floor_index_record(int $topic_id, int $reply_id, int $created_at): void { if (!app_topics_del_ready()) return; q('INSERT INTO app_topics_del(topic_id,reply_id,created_at) VALUES(?,?,?)', [$topic_id, $reply_id, $created_at]); floor_index_clear(); } function topic_floor_index(int $topic_id): array { if (isset($GLOBALS['__floor_cache'][$topic_id])) return $GLOBALS['__floor_cache'][$topic_id]; if (!app_topics_del_ready()) return $GLOBALS['__floor_cache'][$topic_id] = []; $rows = q('SELECT reply_id AS id,created_at FROM app_topics_del WHERE topic_id=? ORDER BY created_at,id', [$topic_id])->fetchAll(); return $GLOBALS['__floor_cache'][$topic_id] = array_map(static fn(array $r): array => ['id' => (int)$r['id'], 'created_at' => (int)$r['created_at']], $rows); } function gap_before(array $del, int $created_at, int $reply_id): int { $lo = 0; $hi = count($del); while ($lo < $hi) { $mid = ($lo + $hi) >> 1; if ($del[$mid]['created_at'] < $created_at || ($del[$mid]['created_at'] === $created_at && $del[$mid]['id'] < $reply_id)) $lo = $mid + 1; else $hi = $mid; } return $lo; } function reply_position_floor(int $topic_id, int $created_at, int $reply_id): int { $floor = 1 + (int)q('SELECT COUNT(*) FROM app_replies WHERE topic_id=? AND (created_atfetchColumn(); foreach (topic_floor_index($topic_id) as $gap) { if ($gap['created_at'] < $created_at || ($gap['created_at'] === $created_at && $gap['id'] < $reply_id)) $floor++; } return $floor; } function floor_live_position(int $topic_id, int $floor): int { $pos = $floor; $i = 0; foreach (topic_floor_index($topic_id) as $gap) { $i++; $live_before = (int)q('SELECT COUNT(*) FROM app_replies WHERE topic_id=? AND (created_atfetchColumn(); if ($live_before + $i < $floor) $pos--; } return max(1, $pos); } function apply_reply_floors(array $replies, array $topic, int $page, int $size, bool $reply_desc): array { $off = ($page - 1) * $size; $del_index = topic_floor_index((int)$topic['id']); foreach ($replies as $i => $reply) { if (!is_array($reply)) continue; $floor = $reply_desc ? (int)$topic['reply_count'] - $off - $i + gap_before($del_index, (int)($reply['created_at'] ?? 0), (int)($reply['id'] ?? 0)) : $off + $i + 1 + gap_before($del_index, (int)($reply['created_at'] ?? 0), (int)($reply['id'] ?? 0)); $replies[$i]['reply_floor'] = max(1, $floor); } return $replies; } function del(string $table, int $id, bool $with_replies = false): 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, $with_replies) { if ($table === 'replies') { if (trim((string)$record['body']) === '') err('回复已删除'); fire('content.before_delete', ['table' => 'replies', 'row' => $record]); reply_fts_delete($id); floor_index_record((int)$record['topic_id'], $id, (int)$record['created_at']); q('DELETE FROM app_replies WHERE id=?', [$id]); refresh_topic_stats((int)$record['topic_id']); content_delete_notify($record, true, (int)$record['topic_id']); return; } if ($table === 'topics' && $with_replies) { foreach (q("SELECT * FROM app_replies WHERE topic_id=?", [$id])->fetchAll() as $reply) { fire('content.before_delete', ['table' => 'replies', 'row' => $reply, 'cascade' => true]); reply_fts_delete((int)$reply['id']); floor_index_record((int)$reply['topic_id'], (int)$reply['id'], (int)$reply['created_at']); q('DELETE FROM app_replies WHERE id=?', [(int)$reply['id']]); } } 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 === 'topics') content_delete_notify($record, false); if ($table === 'users') { foreach ($affected_topics as $topic) refresh_topic_stats((int)$topic['topic_id']); } }); 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操作次数已达上限'); hook('login.before_submit', true, []); $u = one("SELECT id,password FROM app_users WHERE username=?", [post('username', DB_STRING_MAX_LENGTH)]); 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') . '

    登录

    ' . form_token() . input('用户名', 'username', '', 'text', true) . input('密码', 'password', '', 'password', true) . $form_extra . '
    ' . $auth_extra . '
    ', $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 = ''; page('注册', shell_html(auth_tabs_html('register') . '

    注册

    ' . form_token() . $username . input('密码', 'password', '', 'password', true) . input('确认密码', 'password2', '', 'password', true) . input('邮箱', 'email', '', 'email') . $form_extra . '
    ', $sidebar)); } function profile_page(): void { need_login(); $u = me(); $profile_tab_input = $_GET['tab'] ?? 'profile'; $profile_tab = is_string($profile_tab_input) ? cut(trim($profile_tab_input), 64) : 'profile'; if ($profile_tab === '') $profile_tab = 'profile'; $default_profile_tabs = [ 'home' => ['label' => '我的主页', 'href' => route_url('user', ['id' => (int)$u['id']])], 'profile' => ['label' => '个人设置', 'href' => route_url('profile')], ]; $profile_tabs = $default_profile_tabs; $hook_tabs = hook('profile.settings_tabs', $profile_tabs, ['user' => $u, 'tab' => $profile_tab]); if (is_array($hook_tabs)) $profile_tabs = $hook_tabs; $profile_tabs += $default_profile_tabs; if (!array_key_exists($profile_tab, $profile_tabs)) $profile_tab = 'profile'; $body = '
    ' . tab_bar_html($profile_tabs, $profile_tab, '', 'profile.settings_tabs') . '
    '; if ($profile_tab === 'profile') { if (is_post_request()) { save_user(false, uid()); set_flash('个人资料已保存'); go(route_url('profile')); } $account_cards = ''; $password_fields = '
    密码不修改密码
    '; $profile_extra = (string)hook('profile.after_form', '', ['user' => $u]); $body .= '
    ' . $account_cards . '
    ' . form_token() . avatar_picker_html($u) . textarea('简介', 'bio', $u['bio']) . $password_fields . '
    ' . $profile_extra . '
    '; } else { $tab_content = (string)hook('profile.settings_tab_content', '', ['user' => $u, 'tab' => $profile_tab, 'tabs' => $profile_tabs]); $body .= '
    ' . $tab_content . '
    '; } page('个人设置', form_shell($body, $u)); } function user_page(): void { $username = is_string($_GET['username'] ?? null) ? cut(trim((string)$_GET['username']), DB_STRING_MAX_LENGTH) : ''; 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'; $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, bool $tab_allowed = true, string $denied_notice = ''): 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]; if ($profile_uid && !$tab_allowed) { return ['rows' => [], 'total' => 0, 'profile_empty' => $denied_notice !== '' ? $denied_notice : '该内容不对外开放', 'unread_total' => 0, 'simple_pagination' => false, 'has_next_page' => false]; } $data = hook('topic.index_data.load', null, $ctx); if (is_array($data)) { $loaded = hook('topic.index_data.loaded', $data, $ctx); return is_array($loaded) ? $loaded : $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') { $notification_box = notification_box(); $total = notifications_total($profile_uid, $notification_box); $unread_total = $notification_box === 'all' ? notifications_unread_total($profile_uid) : 0; $rows = notifications_list($profile_uid, $size, $offset, $notification_box); } 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; } $site_stats = hook('site_stats.data', null); $total = $query !== '' ? 0 : (($fid || $profile_uid) ? (int)val("SELECT COUNT(*) FROM app_topics $where", $params) : (is_array($site_stats) && array_key_exists('topics', $site_stats) ? (int)$site_stats['topics'] : (int)val('SELECT COUNT(*) FROM app_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, ]; $loaded = hook('topic.index_data.loaded', $data, $ctx); if (is_array($loaded)) $data = $loaded; return $data; } function topic_index_template(array $view): string { extract($view, EXTR_SKIP); $before = hook('topic.index_template.before', null, $view); if (is_string($before)) return (string)hook('topic.index_template', $before, $view); $main = ''; $search_query = $q !== '' ? 'q=' . rawurlencode($q) . '&field=' . $search_field . '&' : ''; if ($profile_uid) { $main .= '
    ' . tab_bar_html($profile_tabs, $profile_tab) . '
    '; } else { if ($q === '') { $forum_links = '
    全部'; foreach (forums_cache() as $f) { if (!forum_group_allowed($f, 'allow_view_groups')) continue; $forum_links .= '' . h($f['name']) . ''; } $main .= $forum_links . '
    '; } $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 .= '
    ' . tab_bar_html($tab_items, $sort) . $toolbar_actions . (can_speak() ? '+ 发帖' : '') . '
    '; } $profile_header = ($profile_uid && $profile_tab_allowed) ? (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 . '
      '; if ($profile_uid && $profile_tab_allowed && $profile_tab === 'notifications') { $notification_box = notification_box(); $main .= '
      ' . tab_bar_html([ 'all' => ['label' => '全部', 'href' => $url('tab=notifications&box=all')], 'inbox' => ['label' => '收件箱', 'href' => $url('tab=notifications&box=inbox')], 'outbox' => ['label' => '发件箱', 'href' => $url('tab=notifications&box=outbox')], ], $notification_box, 'plugin-tabs', 'user.notifications.tabs') . '
      '; if (!$rows) $main .= '
    • 暂无通知
    • '; else foreach ($rows as $i => $n) { if ((int)($n['sender_id'] ?? 0) === uid()) { $n['read_at'] = 1; $n['sender_id'] = (int)$n['recipient_id']; $n['kind'] = 'sent'; } if ($unread_total > 0 && $off + $i === $unread_total) $main .= '
    • 下面的通知已读
    • '; $main .= notification_row_html($n); } } elseif (!$rows) { $empty = $profile_empty !== '' ? $profile_empty : ($profile_uid ? ($profile_tab === 'replies' ? '暂无回帖' : '暂无主题') : '暂无主题'); $main .= '
    • ' . ($q !== '' ? '没有找到匹配的' . ($search_field === 'reply' ? '回帖' : '主题') : $empty) . '
    • '; } else foreach ($rows as $t) { $t['time'] = (int)($t['list_time'] ?? $t['my_reply_at'] ?? ($sort === 'post' ? $t['created_at'] : ($t['last_reply_at'] ?: $t['created_at']))); $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 . ($profile_tab === 'notifications' ? '&box=' . notification_box() : '') : 'sort=' . $sort); $pagination = $simple_pagination ? simple_paginate($p > 1, $has_next_page, $p, $url($page_query)) : paginate($total, $p, $size, $url($page_query)); $main .= '
    ' . ($pagination !== '' ? '
    ' . $pagination . '
    ' : ''); if ($profile_uid && $profile_tab_allowed) $main .= (string)hook('user.profile_tab_footer', '', ['user' => $filter_user, 'self' => $own_profile, 'tab' => $profile_tab, 'page' => $p, 'page_size' => $size, 'total' => $total]); $is_home_first_page = !$profile_uid && !$filter_forum && $q === '' && $p === 1; $sidebar = sidebar_stack_html([sidebar_user_card_html($profile_uid ? $filter_user : null, false, $fid)], ['is_home_first_page' => $is_home_first_page, 'user' => $filter_user]); $shell_class = $profile_uid ? 'profile-mobile-sidebar' . ($own_profile ? ' profile-mobile-sidebar-own' : '') : ($is_home_first_page ? 'home-mobile-sidebar' : ''); return (string)hook('topic.index_template', shell_html($main, $sidebar, $shell_class), $view); } 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 = ''; $search_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']; } $profile_tab_allowed = true; $profile_tab_notice = ''; if ($profile_uid) { $allowed = hook('user.profile_tab_allowed', true, ['user' => $filter_user, 'self' => $own_profile, 'tab' => $profile_tab]); if ($allowed === false) { $profile_tab_allowed = false; $profile_tab_notice = '该内容不对外开放'; } elseif (is_string($allowed) && trim($allowed) !== '') { $profile_tab_allowed = false; $profile_tab_notice = trim($allowed); } } $data = topic_index_data($fid, $filter_user, $profile_tab, $q, $search_field, $sort, $p, $size, $profile_tab_allowed, $profile_tab_notice); if ($profile_uid && $profile_tab_allowed && $profile_tab === 'notifications' && notification_box() === 'all') mark_notifications_read($profile_uid, (int)$data['unread_total']); $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'])); $view = array_merge($data, compact('filter_forum', 'filter_user', 'fid', 'profile_uid', 'own_profile', 'url', 'p', 'size', 'off', 'profile_tab', 'sort', 'q', 'search_field', 'profile_tabs', 'profile_tab_allowed'), ['forum' => $filter_forum, 'user' => $filter_user, 'forum_id' => $fid, 'page' => $p, 'page_size' => $size, 'offset' => $off]); page($title, topic_index_template($view), $seo); } function home_page(): void { topic_index_page(); } function forum_page(): void { $fid = id(); $f = forum_by_id($fid) ?: err('你访问的页面不存在', 404); if (!forum_group_allowed($f, 'allow_view_groups')) err('无权限'); 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)) { $loaded = hook('topic.replies_data.loaded', $data, $ctx); return is_array($loaded) ? $loaded : $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]; $loaded = hook('topic.replies_data.loaded', $data, $ctx); if (is_array($loaded)) $data = $loaded; return $data; } function topic_page_template(array $view): string { extract($view, EXTR_SKIP); $before = hook('topic.template.before', null, $view); if (is_string($before)) return (string)hook('topic.template', $before, $view); $topic_ops = uid() ? quote_reply_action($t) : ''; $topic_ops = (string)hook('topic.actions', $topic_ops, ['topic' => $t]); $breadcrumb = ''; $topic_url = route_url('topic', ['id' => (int)$t['id']]); $main = '
    ' . $breadcrumb . '

    ' . h($t['title']) . '

    ' . topic_stats_html((int)$t['view_count'], (int)$t['reply_count']) . '
      '; if ($p === 1) $main .= topic_post_row($t, $t['body'], (int)$t['created_at'], $topic_ops); foreach ($replies as $i => $r) { $reply_floor = (int)($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) : ''; $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 .= '
    • 暂无回复
    • '; $pagination = paginate((int)$t['reply_count'], $p, $size, $topic_url, false); $main .= '
    ' . ($pagination !== '' ? '
    ' . $pagination . '
    ' : ''); $can_reply_forum = forum_group_allowed($forum, 'allow_reply_groups'); $reply_status = uid() ? (can_speak() ? ($can_reply_forum ? '说两句' : '无回帖权限') : '禁止发言') : '登录后回复'; $main .= '

    发表回复

    ' . $reply_status . '
    '; if (can_speak() && $can_reply_forum) { $reply_form_extra = (string)hook('reply.form_extra', '', ['topic' => $t, 'editing' => false]); $main .= '
    ' . form_token() . '' . textarea('内容', 'body', '', true) . $reply_form_extra . '
    '; } elseif (!uid()) $main .= ''; elseif (!$can_reply_forum) $main .= ''; else $main .= ''; $html = shell_html($main . '
    ', sidebar_stack_html([sidebar_user_card_html(null, true)])); return (string)hook('topic.template', $html, $view); } 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 (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) { $gap_count = count(topic_floor_index((int)$t['id'])); if ($floor > (int)$t['reply_count'] + $gap_count) { $floor = (int)$t['reply_count'] + $gap_count; $_GET['floor'] = (string)$floor; } $anchor_pos = floor_live_position((int)$t['id'], $floor); $_GET['p'] = (string)max(1, $reply_desc ? (int)(((int)$t['reply_count'] - $anchor_pos) / $size) + 1 : (int)(($anchor_pos - 1) / $size) + 1); } elseif ($replyid > 0) { $reply = row('app_replies', 'id', $replyid); if ($reply && (int)$reply['topic_id'] !== (int)$t['id']) $reply = null; if ($reply) { $anchor_pos = floor_live_position((int)$t['id'], reply_position_floor((int)$t['id'], (int)$reply['created_at'], $replyid)); $_GET['p'] = (string)max(1, $reply_desc ? (int)(((int)$t['reply_count'] - $anchor_pos) / $size) + 1 : (int)(($anchor_pos - 1) / $size) + 1); } 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 = apply_reply_floors($page_data['replies'], $t, $p, $size, $reply_desc); $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']]); $view = compact('t', 'forum', 'replies', 'p', 'size', 'off', 'reply_desc', 'replyid', 'floor') + ['topic' => $t, 'page' => $p, 'page_size' => $size, 'offset' => $off, 'reply_order' => $reply_desc ? 1 : 0]; page($t['title'] . ' - ' . $forum['name'], topic_page_template($view), 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 = topic_title_color((string)($t['highlight_style'] ?? '')); $is_large = topic_title_is_large((string)($t['highlight_style'] ?? '')); $is_pinned = in_array((int)$t['id'], pinned_topic_ids(), true); $colors = ['#d94b4b', '#d97706', '#16a34a', '#2563eb', '#7c3aed']; $swatches = '
    '; foreach ($colors as $color) $swatches .= ''; $swatches .= ''; $swatches .= '
    '; $pin_options = ''; $enlarge_options = ''; $topic_ops = ''; } $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]); if ($form_extra !== '') $form_extra = '
    ' . $form_extra . '
    '; $form_sidebar = (string)hook('topic.form_sidebar', '', ['topic' => $t, 'editing' => $editing]); $topic_loading_text = $editing ? '正在保存' : '正在发帖'; page($title, shell_html('

    ' . $title . '

    ' . form_token() . '' . select_forum((int)$t['forum_id']) . input('标题', 'title', $t['title'], 'text', true) . textarea('内容', 'body', $t['body'], true) . $attachments . $reply_order . $form_extra . $topic_ops . '
    ', 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 (is_post_request() && ($_POST['do'] ?? '') === 'delete') { if (trim((string)$r['body']) === '') err('回复已删除'); tx(function () use ($r) { fire('content.before_delete', ['table' => 'replies', 'row' => $r]); reply_fts_delete((int)$r['id']); floor_index_record((int)$r['topic_id'], (int)$r['id'], (int)$r['created_at']); q('DELETE FROM app_replies WHERE id=?', [(int)$r['id']]); refresh_topic_stats((int)$r['topic_id']); content_delete_notify($r, true, (int)$r['topic_id']); }); go(route_url('topic', ['id' => (int)$r['topic_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 = reply_position_floor((int)$topic['id'], (int)$row['created_at'], (int)$row['id']); $ops = quote_reply_action($row, $floor); 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']])); } $reply_ops = (int)$r['id'] > 0 ? '' : ''; $reply_form_extra = (string)hook('reply.form_extra', '', ['reply' => $r, 'editing' => (int)$r['id'] > 0]); page('编辑回复', form_shell('

    编辑回复

    ' . form_token() . '' . textarea('内容', 'body', $r['body'], true) . (string)hook('attachment.uploader', '', ['muted' => true]) . $reply_form_extra . $reply_ops . '
    ')); } 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', 'admin.tabs'); } 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 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 = Admin::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 mobile_menu_route(): void { header('Content-Type: text/html; charset=utf-8'); echo mobile_menu_content_html(me()); } 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::class, 'page'], 'mobile_menu' => 'mobile_menu_route', '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::class, 'route'], 'cron' => [Cron::class, 'cron_route'], 'opcache_refresh' => [Admin::class, '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); }