95 lines
4.0 KiB
PHP
95 lines
4.0 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;
|
|
}
|
|
|
|
/** 消毒 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);
|
|
}
|
|
}
|