2026-09-22 22:03:08 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
function e($value): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 读取 config.php(每个请求只读一次)
|
|
|
|
|
|
* 页面里的第三方 Key 一律从这里取,避免同一个值散落在多个文件里
|
|
|
|
|
|
*/
|
|
|
|
|
|
function app_config(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
static $config = null;
|
|
|
|
|
|
if ($config === null) {
|
|
|
|
|
|
$config = require dirname(__DIR__) . '/config.php';
|
|
|
|
|
|
}
|
|
|
|
|
|
return $config;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function url(string $route, array $params = []): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$params = array_merge(['r' => $route], $params);
|
|
|
|
|
|
return '/index.php?' . http_build_query($params);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 07:06:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 静态资源地址 + 内容版本号(cache busting)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么必须有:CSS/JS 的响应没有 Cache-Control/Expires,浏览器会退回到自己的
|
|
|
|
|
|
* 「启发式缓存」——在它自行估算的新鲜期内**根本不发请求**。文件换了它也不知道,
|
|
|
|
|
|
* 因为地址一直没变。表现就是「服务端明明更新了,用户看到的还是旧样式」。
|
|
|
|
|
|
* 拼上随文件变化的版本号后,文件一改地址就变,浏览器只能重新拉。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 版本号用 mtime + 文件大小:只用 mtime 时,同一秒内改两次会漏;
|
|
|
|
|
|
* 补上大小能挡住绝大多数「内容变了但 mtime 没变」的情况。
|
|
|
|
|
|
* 取不到文件信息就不加版本号,退化成原来的行为 —— 不影响可用性。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function asset(string $path): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$path = '/' . ltrim($path, '/');
|
|
|
|
|
|
$file = dirname(__DIR__) . $path;
|
|
|
|
|
|
$mtime = @filemtime($file);
|
|
|
|
|
|
if ($mtime === false) {
|
|
|
|
|
|
return $path;
|
|
|
|
|
|
}
|
|
|
|
|
|
return $path . '?v=' . $mtime . '-' . (int)@filesize($file);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
|
function redirect(string $route, array $params = [], string $anchor = ''): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$location = url($route, $params);
|
|
|
|
|
|
if ($anchor !== '') {
|
|
|
|
|
|
$location .= '#' . ltrim($anchor, '#');
|
|
|
|
|
|
}
|
|
|
|
|
|
header('Location: ' . $location);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function view(string $template, array $data = []): void
|
|
|
|
|
|
{
|
|
|
|
|
|
extract($data, EXTR_SKIP);
|
|
|
|
|
|
$basePath = dirname(__DIR__);
|
|
|
|
|
|
$content = $basePath . '/templates/' . $template . '.php';
|
|
|
|
|
|
if (!file_exists($content)) {
|
|
|
|
|
|
throw new \RuntimeException("模板不存在: $template");
|
|
|
|
|
|
}
|
|
|
|
|
|
require $basePath . '/templates/layout.php';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function flash(string $key, ?string $value = null): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($value !== null) {
|
|
|
|
|
|
$_SESSION['_flash'][$key] = $value;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$v = $_SESSION['_flash'][$key] ?? null;
|
|
|
|
|
|
unset($_SESSION['_flash'][$key]);
|
|
|
|
|
|
return $v;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function csrf_token(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (empty($_SESSION['_csrf'])) {
|
|
|
|
|
|
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
|
|
|
|
|
|
}
|
|
|
|
|
|
return $_SESSION['_csrf'];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 校验 CSRF。两个通道都收:
|
|
|
|
|
|
* 表单 → 隐藏字段 _csrf
|
|
|
|
|
|
* XHR → 请求头 X-CSRF-Token(自定义头跨站加不上,同样安全,且不必把 token 塞进 body)
|
|
|
|
|
|
*/
|
2026-09-22 22:03:08 +08:00
|
|
|
|
function csrf_check(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$token = $_POST['_csrf'] ?? '';
|
2026-09-22 23:09:37 +08:00
|
|
|
|
if ($token === '') {
|
|
|
|
|
|
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!hash_equals($_SESSION['_csrf'] ?? '', (string)$token)) {
|
2026-09-22 22:03:08 +08:00
|
|
|
|
http_response_code(419);
|
|
|
|
|
|
exit('CSRF token 校验失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:09:37 +08:00
|
|
|
|
/** 把 php.ini 里的 "8M" / "512K" 这类值换算成字节 */
|
|
|
|
|
|
function ini_bytes(string $value): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$value = trim($value);
|
|
|
|
|
|
if ($value === '' || $value === '-1') {
|
|
|
|
|
|
return 0; // 不限
|
|
|
|
|
|
}
|
|
|
|
|
|
$unit = strtolower(substr($value, -1));
|
|
|
|
|
|
$num = (int)$value;
|
|
|
|
|
|
switch ($unit) {
|
|
|
|
|
|
case 'g': return $num * 1024 * 1024 * 1024;
|
|
|
|
|
|
case 'm': return $num * 1024 * 1024;
|
|
|
|
|
|
case 'k': return $num * 1024;
|
|
|
|
|
|
default: return $num;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
|
function json_decode_field($json, $default = [])
|
|
|
|
|
|
{
|
|
|
|
|
|
if (empty($json)) return $default;
|
|
|
|
|
|
if (is_array($json)) return $json;
|
|
|
|
|
|
$decoded = json_decode($json, true);
|
|
|
|
|
|
return $decoded ?? $default;
|
2026-09-22 23:09:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 附件的查看地址 —— 走 media/show 代理,页面里**永远不出现存储后端的真实地址**
|
|
|
|
|
|
* (OSS 桶是私有的,直链既取不到、也不该暴露给浏览器)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function media_att(int $attachmentId, bool $download = false): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return url('media/show', $download ? ['id' => $attachmentId, 'dl' => 1] : ['id' => $attachmentId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 活动封面地址(同样走代理) */
|
|
|
|
|
|
function media_cover(int $activityId): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return url('media/cover', ['id' => $activityId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 头像地址。头像是本站本地文件,但仍做一次前缀校验,
|
|
|
|
|
|
* 避免历史数据里塞了外部地址把图片挂到第三方去。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function media_avatar(?string $url): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$url = trim((string)$url);
|
|
|
|
|
|
if ($url === '' || strpos($url, '/uploads/avatars/') !== 0 || strpos($url, '..') !== false) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
return $url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 人类可读的文件大小 */
|
|
|
|
|
|
function human_size(?int $bytes): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$bytes = (int)$bytes;
|
|
|
|
|
|
if ($bytes <= 0) return '';
|
|
|
|
|
|
if ($bytes < 1024) return $bytes . ' B';
|
|
|
|
|
|
if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . ' KB';
|
|
|
|
|
|
return round($bytes / 1024 / 1024, 2) . ' MB';
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|