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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|