Files
coolcoth.com/app/Controllers/Admin/AdminController.php
T
2026-08-08 17:19:44 +08:00

124 lines
5.1 KiB
PHP

<?php
namespace App\Controllers\Admin;
use App\Controllers\Controller;
/** 后台基控制器:需要登录 */
class AdminController extends Controller
{
protected $layout = 'layouts/admin';
public function __construct()
{
admin_required();
}
/** 当前路由片段,用于侧栏高亮 */
protected function seg(): string
{
$u = trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/');
$parts = explode('/', $u);
return $parts[1] ?? '';
}
/**
* 处理上传文件,返回相对站点根的路径(如 assets/uploads/x.jpg),无上传则返回 null。
* 安全红线:
* 1) 仅凭扩展名不可信 —— 光栅图用 getimagesize 校验真实图像内容;
* 2) 文件体积上限 8MB,防止磁盘打满 / DoS;
* 3) SVG 可内嵌 <script>/on* 事件 → 存储型 XSS,落盘前强制消毒;
* 4) 落盘文件名随机化,杜绝路径穿越与覆盖。
*/
protected function uploadFile(string $key): ?string
{
if (empty($_FILES[$key]['tmp_name'])) return null;
$f = $_FILES[$key];
if ($f['error'] !== UPLOAD_ERR_OK) return null;
if (!is_uploaded_file($f['tmp_name'])) return null;
if (($f['size'] ?? 0) > 8 * 1024 * 1024) return null; // 8MB 上限
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
if (!in_array($ext, $allowed, true)) return null;
// 真实类型校验(不信任扩展名与浏览器提交的 MIME)
$realMime = function_exists('finfo_open')
? (finfo_file(($fi = finfo_open(FILEINFO_MIME_TYPE)), $f['tmp_name']) ?: '') : '';
if (isset($fi) && $fi) { finfo_close($fi); }
$isSvg = ($ext === 'svg');
if (!$isSvg) {
// 光栅图:必须能被 GD 识别为真实图像,且 MIME 属于图片类
$info = @getimagesize($f['tmp_name']);
$okMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if ($info === false) return null;
if ($realMime && !in_array($realMime, $okMimes, true)) return null;
} else {
// SVG:类型须为 svg/xml/text,随后消毒内容
$svgMimes = ['image/svg+xml', 'text/plain', 'text/xml', 'application/xml'];
if ($realMime && !in_array($realMime, $svgMimes, true)) return null;
}
$dir = BASE_PATH . '/public/assets/uploads';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$name = uniqid('u_') . '.' . $ext;
$dest = $dir . '/' . $name;
if ($isSvg) {
// 读取 → 消毒 → 写入(不使用 move_uploaded_file,因内容已被改写)
$raw = @file_get_contents($f['tmp_name']);
if ($raw === false) return null;
$clean = $this->sanitizeSvg($raw);
if ($clean === '' || @file_put_contents($dest, $clean) === false) return null;
} else {
if (!move_uploaded_file($f['tmp_name'], $dest)) return null;
}
return 'assets/uploads/' . $name;
}
/**
* 处理多文件上传(name="gallery[]"),返回相对站点根路径数组。
* 逐张复用 uploadFile 的校验逻辑(真实图像/体积上限/类型白名单/SVG 消毒),
* 任一文件失败不影响其余文件。
*/
protected function uploadFiles(string $key): array
{
if (empty($_FILES[$key]['tmp_name']) || !is_array($_FILES[$key]['tmp_name'])) return [];
$names = $_FILES[$key]['name'] ?? [];
$errors = $_FILES[$key]['error'] ?? [];
$sizes = $_FILES[$key]['size'] ?? [];
$out = [];
foreach ($_FILES[$key]['tmp_name'] as $i => $tmp) {
if (empty($tmp)) continue;
// 桥接到单文件校验逻辑
$_FILES['_multi_tmp'] = [
'name' => $names[$i] ?? 'x.bin',
'type' => '',
'tmp_name' => $tmp,
'error' => $errors[$i] ?? UPLOAD_ERR_OK,
'size' => $sizes[$i] ?? 0,
];
$rel = $this->uploadFile('_multi_tmp');
unset($_FILES['_multi_tmp']);
if ($rel) $out[] = $rel;
}
return $out;
}
/** 消毒 SVG:移除脚本、事件处理器与危险协议,阻断存储型 XSS */
protected function sanitizeSvg(string $svg): string
{
// 去除 <script>...</script>
$svg = preg_replace('#<script[^>]*>.*?</script>#is', '', $svg);
// 去除 <foreignObject>(可嵌 HTML/脚本)
$svg = preg_replace('#<foreignObject[^>]*>.*?</foreignObject>#is', '', $svg);
// 去除内联事件处理器 on*="..."
$svg = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $svg);
// 去除 javascript: / data:text 等危险协议引用
$svg = preg_replace('#(href|xlink:href)\s*=\s*("|\')?\s*javascript:[^"\'>]*#i', '', $svg);
// 去除 <use> 外部引用与 <a> 标签,避免脚本跳转
$svg = preg_replace('#<a[^>]*>|</a>#i', '', $svg);
return trim((string)$svg);
}
}