'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 ? '未读' : '') . '
' . human_time((int)$n['created_at']) . '
' . $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 = '';
}
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 .= '';
}
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 ? '' . svg_icon('user') . '' . 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 = '';
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 '';
}
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 '
';
}
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) . '头像当前头像
' . avatar_tag($uid, $name, $style, '', $seed) . '
';
if (!$local_only) $html .= '';
foreach ($seeds as $s) $html .= '';
return $html . '
';
}
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'] ?? '') !== '匿名') ? '' . svg_icon('id') . '' . (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 . '
' . $user_link . '' . human_time($time) . '
';
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 ? '' . h($forum['name']) . '' : '';
$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 . '
' . $meta . '
' . $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 '' . 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 = '';
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_at OR (created_at=? AND id))', [$topic_id, $created_at, $created_at, $reply_id])->fetchColumn();
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_at OR (created_at=? AND id))', [$topic_id, (int)$gap['created_at'], (int)$gap['created_at'], (int)$gap['id']])->fetchColumn();
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') . '登录
' . $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') . '注册
', $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 = '用户名' . h($u['username']) . '
用户 UID' . (int)$u['id'] . '
邮箱' . h($u['email']) . '
注册时间' . date('Y-m-d H:i', (int)$u['created_at']) . '
积分' . (int)$u['points'] . '
';
$password_fields = '密码不修改密码
' . input('新密码', 'password', '', 'password') . input('确认密码', 'password2', '', 'password') . '
';
$profile_extra = (string)hook('profile.after_form', '', ['user' => $u]);
$body .= '' . $account_cards . '
' . $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 .= '';
}
$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 !== '' ? '' : '');
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 = '';
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 !== '' ? '' : '');
$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 .= '
';
} 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('', 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(''));
}
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);
}