交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - trip_nodes 的历史列保留但不再写入也不再渲染,编辑一次即被显式清成 NULL - 节点表单按出行方式切换号码标签(航班号 / 车次 / 班次 / 线路 / 航次) 照片 - 一个节点可传多张照片,前端支持逐张裁剪(Cropper.js,只把裁过的走 base64) - MIME 由自建的 src/Mime.php 按内容识别:getimagesize → 魔数嗅探 → 扩展名映射, 刻意不依赖 fileinfo —— 线上同时装着 PHP 7.4(没装该扩展)与 8.1 - 单张体积上限、可传张数在设置页可调 双存储后端 - 本站 uploads/ 或阿里云 OSS,在「存储设置」页配 endpoint / 桶名 / 前缀 / 内网开关; OSS 配置不完整时静默回退本地,避免管理员填错就把上传整个弄挂 - OSS 鉴权支持 ECS 实例 RAM 角色换 STS 临时凭证(临时凭证缓存在 /tmp,不落 Web 目录), 也支持直接填 AccessKey - OSS V1 签名手写实现,不引 SDK(本项目无 composer / 无 autoload) 媒体代理 - 照片与活动封面一律经 media/show、media/cover 代理输出,先校验家庭归属再吐字节 - 页面里不出现 OSS 域名,也不出现本地真实路径;桶保持私有 - 响应带 nosniff,并关闭 display_errors,免得 PHP 报错混进图片正文把图弄坏 数据库 - 新增迁移机制(src/Migrate.php + tools/migrate.php,仅 CLI 可跑) - 建 app_settings 表;attachments 增加 storage / object_key / mime_type / size_bytes / width / height / is_image 与 idx_storage - MySQL 8 不支持「加列时带 IF NOT EXISTS」,迁移一律先查 information_schema 界面 - 顶栏只留品牌与「新建活动」,其余入口收进右上角下拉菜单(details/summary,零 JS 可开合) - 新增「存储设置」页:当前生效存储、数据库结构版本、是否 ECS 运行环境三张状态卡, 外加测试连接与角色探测 - 样式表整体重写 验证 - 模板渲染检查 234 项、SQL 列名静态检查、模板到 CSS 的类覆盖检查、内联 JS 语法检查全通过 - 已部署到 trip.yangyq.net 并完成线上验证:双 PHP(8.1 / 7.4)14 条路由渲染字节数完全一致、 26 项内容断言全部通过、照片代理取件与磁盘原件逐字节一致(69/69 字节)
This commit is contained in:
@@ -3,10 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Attachments;
|
||||
use App\Auth;
|
||||
use App\Comments;
|
||||
use App\Database;
|
||||
use App\Expenses;
|
||||
use App\Mime;
|
||||
|
||||
class ActivityController
|
||||
{
|
||||
@@ -143,32 +145,17 @@ class ActivityController
|
||||
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
|
||||
$id = (int)$activity['id'];
|
||||
|
||||
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清
|
||||
$files = [];
|
||||
$stmt = $db->prepare('SELECT id, file_url FROM attachments
|
||||
WHERE target_type = "activity" AND target_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$files[] = $row['file_url'];
|
||||
}
|
||||
|
||||
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清。
|
||||
// 走到 Attachments::purgeTarget 里统一处理,它同时会清掉本地文件或 OSS 对象 ——
|
||||
// 以前这里按 file_url 前缀判断,OSS 存储的附件根本删不掉。
|
||||
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
|
||||
if (!empty($nodeIds)) {
|
||||
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
|
||||
$stmt = $db->prepare("SELECT file_url FROM attachments
|
||||
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
|
||||
$stmt->execute($nodeIds);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$files[] = $row['file_url'];
|
||||
}
|
||||
$stmt = $db->prepare("DELETE FROM attachments
|
||||
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
|
||||
$stmt->execute($nodeIds);
|
||||
|
||||
Attachments::purgeTarget(Attachments::TARGET_ACTIVITY, $id);
|
||||
foreach ($nodeIds as $nodeId) {
|
||||
Attachments::purgeTarget(Attachments::TARGET_NODE, $nodeId);
|
||||
}
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "activity" AND target_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
|
||||
// 评论同样是软关联
|
||||
if (!empty($nodeIds)) {
|
||||
@@ -183,12 +170,7 @@ class ActivityController
|
||||
$stmt = $db->prepare('DELETE FROM activities WHERE id = ? AND family_id = ?');
|
||||
$stmt->execute([$id, Auth::familyId()]);
|
||||
|
||||
// 数据库删干净了再删物理文件,避免删早了但事务回滚留下破损记录
|
||||
// 注意:附件文件在 /uploads/attachments/ 下,不能拿「只认 covers」的
|
||||
// deleteCoverFile() 去删 —— 那样判定不通过,文件会变成永远删不掉的孤儿
|
||||
foreach ($files as $url) {
|
||||
$this->deleteUploadFile((string)$url);
|
||||
}
|
||||
// 数据库删干净了再删封面文件;附件对象已经在 purgeTarget 里删过了
|
||||
if (!empty($activity['cover_image'])) {
|
||||
$this->deleteCoverFile((string)$activity['cover_image']);
|
||||
}
|
||||
@@ -290,14 +272,14 @@ class ActivityController
|
||||
exit;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $raw);
|
||||
finfo_close($finfo);
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
// 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底)
|
||||
$info = Mime::detectBuffer($raw);
|
||||
if ($info['mime'] === null || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$ext = Mime::extFor($info['mime']) ?? $ext;
|
||||
|
||||
$dir = dirname(__DIR__, 2) . '/uploads/covers';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
@@ -353,18 +335,7 @@ class ActivityController
|
||||
return $url;
|
||||
}
|
||||
|
||||
/** 只删除本站上传目录下的文件(封面 / 附件),其余一律不碰 */
|
||||
private function deleteUploadFile(string $url): void
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || strpos($url, '..') !== false) return;
|
||||
if (strpos($url, '/uploads/covers/') !== 0 && strpos($url, '/uploads/attachments/') !== 0) {
|
||||
return;
|
||||
}
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
/** 只删除本站 uploads/covers/ 下的封面文件 */
|
||||
private function deleteCoverFile(string $url): void
|
||||
{
|
||||
$url = trim($url);
|
||||
|
||||
@@ -3,118 +3,163 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Attachments;
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
use App\Mime;
|
||||
use App\Settings;
|
||||
use App\Storage;
|
||||
|
||||
class AttachmentController
|
||||
{
|
||||
private const ALLOWED_MIMES = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/heic' => 'heic',
|
||||
'application/pdf' => 'pdf',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/zip' => 'zip',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/quicktime' => 'mov',
|
||||
];
|
||||
|
||||
private const MAX_SIZE = 20 * 1024 * 1024;
|
||||
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
||||
private const MAX_FILES = 30;
|
||||
|
||||
public function node(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
$nodeId = (int)($_GET['id'] ?? 0);
|
||||
$node = $this->fetchNodeWithAccess($nodeId);
|
||||
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT a.*, u.name AS uploader_name
|
||||
FROM attachments a
|
||||
LEFT JOIN users u ON u.id = a.uploader_id
|
||||
WHERE a.target_type = "trip_node" AND a.target_id = ?
|
||||
ORDER BY a.created_at DESC');
|
||||
$stmt->execute([$nodeId]);
|
||||
$attachments = $stmt->fetchAll();
|
||||
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
|
||||
|
||||
view('nodes/attachments', [
|
||||
'node' => $node,
|
||||
'attachments' => $attachments,
|
||||
'pageTitle' => '节点附件:' . ($node['title'] ?: '无标题'),
|
||||
'node' => $node,
|
||||
'attachments' => Attachments::forTarget(Attachments::TARGET_NODE, $nodeId),
|
||||
'maxMb' => (int)Settings::get('photo_max_mb', '12'),
|
||||
'driverLabel' => Storage::label(),
|
||||
'pageTitle' => '节点照片:' . ($node['title'] ?: '无标题'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传附件。
|
||||
*
|
||||
* 一个请求里同时收两类东西:
|
||||
* files[] —— 普通 multipart 文件(照片原图、PDF、视频……)
|
||||
* cropped[] —— 前端裁好的图片,JSON 字符串 {"name":..,"data":"data:image/...;base64,.."}
|
||||
* 分开传是为了省流量:没裁的照片原图直传,裁过的才走 base64。
|
||||
*/
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$this->fetchNodeWithAccess($nodeId);
|
||||
$node = Access::node($nodeId);
|
||||
|
||||
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
|
||||
flash('error', '未选择文件');
|
||||
$maxBytes = 20 * 1024 * 1024;
|
||||
$photoMax = (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
|
||||
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
$count = 0;
|
||||
|
||||
// ── 1) 普通文件 ──
|
||||
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
|
||||
foreach ($this->restructureFiles($_FILES['files']) as $file) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
||||
continue;
|
||||
}
|
||||
$limit = $photoMax > 0 ? max($photoMax, $maxBytes) : $maxBytes;
|
||||
if ($file['size'] > $limit) {
|
||||
$errors[] = $file['name'] . ':超过大小上限';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $file['name'] . ':不支持的文件类型';
|
||||
continue;
|
||||
}
|
||||
if ($info['is_image'] && $photoMax > 0 && $file['size'] > $photoMax) {
|
||||
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($file['tmp_name']);
|
||||
if ($raw === false) {
|
||||
$errors[] = $file['name'] . ':读取临时文件失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = $this->store($nodeId, $raw, $mime, $file['name'], $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $file['name'] . ':' . $err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2) 前端裁剪后的图片 ──
|
||||
$cropped = $_POST['cropped'] ?? '';
|
||||
if (is_string($cropped) && $cropped !== '') {
|
||||
$items = json_decode($cropped, true);
|
||||
if (!is_array($items)) {
|
||||
$errors[] = '裁剪数据解析失败';
|
||||
$items = [];
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
$name = (string)($item['name'] ?? 'photo.png');
|
||||
$data = (string)($item['data'] ?? '');
|
||||
if (!preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#is', $data, $m)) {
|
||||
$errors[] = $name . ':裁剪结果格式不正确';
|
||||
continue;
|
||||
}
|
||||
$raw = base64_decode($m[2], true);
|
||||
if ($raw === false || $raw === '') {
|
||||
$errors[] = $name . ':图片解码失败';
|
||||
continue;
|
||||
}
|
||||
if (strlen($raw) > $maxBytes) {
|
||||
$errors[] = $name . ':超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectBuffer($raw);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !Mime::isImageMime($mime) || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $name . ':裁剪结果不是受支持的图片';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = $this->store($nodeId, $raw, $mime, $this->asImageName($name, $mime), $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $name . ':' . $err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($count === 0 && $uploaded === 0) {
|
||||
flash('error', '没有选择任何文件');
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
}
|
||||
|
||||
$files = $this->restructureFiles($_FILES['files']);
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
|
||||
$db = Database::get();
|
||||
$uploadDir = dirname(__DIR__, 2) . '/uploads/attachments';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ': 上传错误';
|
||||
continue;
|
||||
}
|
||||
if ($file['size'] > self::MAX_SIZE) {
|
||||
$errors[] = $file['name'] . ': 超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!isset(self::ALLOWED_MIMES[$mime])) {
|
||||
$errors[] = $file['name'] . ': 不支持的类型';
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = self::ALLOWED_MIMES[$mime];
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
$errors[] = $file['name'] . ': 保存失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO attachments
|
||||
(target_type, target_id, uploader_id, file_url, file_name)
|
||||
VALUES ("trip_node", ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$nodeId,
|
||||
Auth::id(),
|
||||
'/uploads/attachments/' . $filename,
|
||||
$file['name'],
|
||||
]);
|
||||
$uploaded++;
|
||||
}
|
||||
|
||||
$msg = "成功上传 {$uploaded} 个文件";
|
||||
$msg = "成功上传 {$uploaded} 个文件(存到" . Storage::label() . ')';
|
||||
if (!empty($errors)) {
|
||||
$msg .= ';失败:' . implode(',', $errors);
|
||||
$msg .= ';失败 ' . count($errors) . ' 个:' . implode(';', array_slice($errors, 0, 5));
|
||||
}
|
||||
if (Storage::ossMisconfigured()) {
|
||||
$msg .= '|注意:已选择 OSS 但配置不完整,本次实际存到了本站服务器';
|
||||
}
|
||||
|
||||
flash('error', $msg);
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
redirect('attachments/node', ['id' => $nodeId], 'photos');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
@@ -122,36 +167,67 @@ class AttachmentController
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM attachments WHERE id = ? AND target_type = "trip_node"');
|
||||
$stmt->execute([$id]);
|
||||
$att = $stmt->fetch();
|
||||
if (!$att) {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$att = Attachments::find($id);
|
||||
if (!$att || $att['target_type'] !== Attachments::TARGET_NODE) {
|
||||
flash('error', '附件不存在');
|
||||
redirect('activities/index');
|
||||
}
|
||||
|
||||
$this->fetchNodeWithAccess((int)$att['target_id']);
|
||||
$node = Access::node((int)$att['target_id']); // 顺带做归属校验
|
||||
Attachments::delete($id);
|
||||
|
||||
$url = $att['file_url'];
|
||||
if (strpos($url, '/uploads/attachments/') === 0) {
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
flash('error', '照片已删除');
|
||||
redirect('attachments/node', ['id' => (int)$node['id']], 'photos');
|
||||
}
|
||||
|
||||
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
|
||||
private function store(int $nodeId, string $raw, string $mime, string $fileName, array $info): ?string
|
||||
{
|
||||
$ext = Mime::extFor($mime);
|
||||
if ($ext === null) {
|
||||
return '无法确定扩展名';
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
try {
|
||||
$key = Storage::keyFor($ext, 'attachments');
|
||||
$stored = Storage::put($key, $raw, $mime);
|
||||
} catch (\Throwable $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
flash('error', '附件已删除');
|
||||
redirect('attachments/node', ['id' => (int)$att['target_id']]);
|
||||
try {
|
||||
Attachments::create(
|
||||
Attachments::TARGET_NODE,
|
||||
$nodeId,
|
||||
(int)Auth::id(),
|
||||
$stored,
|
||||
$fileName,
|
||||
$mime,
|
||||
(bool)$info['is_image'],
|
||||
$info['width'],
|
||||
$info['height']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 入库失败就把刚写的对象删掉,别留孤儿文件
|
||||
Storage::delete($stored['storage'], $stored['key']);
|
||||
return '入库失败:' . $e->getMessage();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 裁剪结果统一成 .jpg/.png 这类名字,避免扩展名与真实格式不符 */
|
||||
private function asImageName(string $name, string $mime): string
|
||||
{
|
||||
$base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo';
|
||||
return $base . '.' . (Mime::extFor($mime) ?? 'jpg');
|
||||
}
|
||||
|
||||
private function restructureFiles(array $files): array
|
||||
{
|
||||
$result = [];
|
||||
$count = count($files['name']);
|
||||
$count = count($files['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (empty($files['name'][$i])) continue;
|
||||
$result[] = [
|
||||
@@ -165,33 +241,10 @@ class AttachmentController
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchNodeWithAccess(int $nodeId): array
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('
|
||||
SELECT tn.*, a.family_id, a.id AS activity_id, a.title AS activity_title
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE tn.id = ?
|
||||
');
|
||||
$stmt->execute([$nodeId]);
|
||||
$node = $stmt->fetch();
|
||||
|
||||
if (!$node) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
if ((int)$node['family_id'] !== Auth::familyId()) {
|
||||
http_response_code(403);
|
||||
exit('无权访问此节点');
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Attachments;
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
use App\Mime;
|
||||
use App\Storage;
|
||||
|
||||
/**
|
||||
* 媒体代理。
|
||||
*
|
||||
* 存在的唯一理由:**桶是私有的,且不该把 OSS 地址暴露给浏览器**。
|
||||
* 所有照片/封面都通过这里以当前登录用户的身份读出、并且**先校验家庭归属**再输出,
|
||||
* 所以把 id 换成别人家的也拿不到东西。
|
||||
*
|
||||
* 页面里因此只出现 media/show?id=12 这样的地址,永远不会出现 oss-*.aliyuncs.com。
|
||||
*/
|
||||
class MediaController
|
||||
{
|
||||
/** GET media/show?id=<attachment_id>[&dl=1] —— 节点附件 */
|
||||
public function show(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
|
||||
$att = Attachments::find((int)($_GET['id'] ?? 0));
|
||||
if (!$att) {
|
||||
$this->abort(404, '附件不存在');
|
||||
}
|
||||
$this->assertTargetInFamily((string)$att['target_type'], (int)$att['target_id']);
|
||||
|
||||
$body = Storage::get(
|
||||
Attachments::storageOf($att),
|
||||
(string)($att['object_key'] ?? ''),
|
||||
(string)($att['file_url'] ?? '')
|
||||
);
|
||||
if ($body === null) {
|
||||
$this->abort(404, '文件已不存在');
|
||||
}
|
||||
|
||||
$mime = (string)($att['mime_type'] ?? '');
|
||||
if ($mime === '') {
|
||||
$mime = Mime::mimeFromName((string)($att['file_name'] ?? '')) ?: 'application/octet-stream';
|
||||
}
|
||||
|
||||
$this->stream(
|
||||
$body,
|
||||
$mime,
|
||||
(string)($att['file_name'] ?? ('attachment-' . $att['id'])),
|
||||
isset($_GET['dl']) && $_GET['dl'] !== ''
|
||||
);
|
||||
}
|
||||
|
||||
/** GET media/cover?id=<activity_id> —— 活动封面 */
|
||||
public function cover(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$stmt = Database::get()->prepare('SELECT family_id, cover_image FROM activities WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row || (int)$row['family_id'] !== (int)Auth::familyId()) {
|
||||
$this->abort(404, '封面不存在');
|
||||
}
|
||||
|
||||
$url = (string)($row['cover_image'] ?? '');
|
||||
// 封面目前只存本站 uploads/covers/ 下;沿用 Storage 的路径校验(挡住 ../)
|
||||
if ($url === '' || strpos($url, '/uploads/covers/') !== 0) {
|
||||
$this->abort(404, '封面不存在');
|
||||
}
|
||||
$key = ltrim(substr($url, strlen('/uploads/')), '/');
|
||||
$body = Storage::get(Storage::DRIVER_LOCAL, $key, $url);
|
||||
if ($body === null) {
|
||||
$this->abort(404, '封面文件已不存在');
|
||||
}
|
||||
|
||||
$mime = Mime::mimeFromName($url) ?: 'image/jpeg';
|
||||
$this->stream($body, $mime, basename($url), false);
|
||||
}
|
||||
|
||||
/** 附件/封面所在的目标必须属于当前用户的家庭 */
|
||||
private function assertTargetInFamily(string $targetType, int $targetId): void
|
||||
{
|
||||
$db = Database::get();
|
||||
|
||||
if ($targetType === Attachments::TARGET_ACTIVITY) {
|
||||
$stmt = $db->prepare('SELECT family_id FROM activities WHERE id = ?');
|
||||
$stmt->execute([$targetId]);
|
||||
$fam = $stmt->fetchColumn();
|
||||
} elseif ($targetType === Attachments::TARGET_NODE) {
|
||||
$stmt = $db->prepare('SELECT a.family_id FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?');
|
||||
$stmt->execute([$targetId]);
|
||||
$fam = $stmt->fetchColumn();
|
||||
} else {
|
||||
// comment 等其它目标:按「评论 → 其挂载目标」回溯太绕,
|
||||
// 一律当作不存在(对外表现与 Access 的 404 一致,不透露资源是否存在)
|
||||
$this->abort(404, '附件不存在');
|
||||
}
|
||||
|
||||
if ($fam === false || (int)$fam !== (int)Auth::familyId()) {
|
||||
$this->abort(404, '附件不存在');
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出二进制内容;$download=true 时强制下载而不是内联预览 */
|
||||
private function stream(string $body, string $mime, string $fileName, bool $download): void
|
||||
{
|
||||
// 别让 PHP 的报错/警告混进图片正文(那会让浏览器把图片判成损坏)
|
||||
@ini_set('display_errors', '0');
|
||||
|
||||
// 文件名里可能有中文和引号,按 RFC 5987 给一个 ASCII 兜底 + UTF-8 版本
|
||||
$ascii = preg_replace('/[^\x20-\x7E]/', '_', $fileName) ?: 'file';
|
||||
$ascii = str_replace(['"', '\\'], '_', $ascii);
|
||||
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . strlen($body));
|
||||
header('Content-Disposition: ' . ($download ? 'attachment' : 'inline')
|
||||
. '; filename="' . $ascii . '"; filename*=UTF-8\'\'' . rawurlencode($fileName));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
// 私有内容:允许浏览器缓存,但禁止中间代理缓存
|
||||
header('Cache-Control: private, max-age=86400');
|
||||
header('Pragma: private');
|
||||
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
private function abort(int $code, string $msg): void
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
exit($msg);
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
// 正常路径上 index.php 已经拦过一道;这里再拦一次只是兜底
|
||||
if (!Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Attachments;
|
||||
use App\Auth;
|
||||
use App\Comments;
|
||||
use App\Database;
|
||||
@@ -59,18 +60,20 @@ class NodeController
|
||||
}
|
||||
|
||||
$data = $input['data'];
|
||||
// 交通信息只留 trip_number(车次/航班号)—— 航班号本身就隐含航空公司,
|
||||
// 座位号/登机口对「一趟车多人同行」没有意义,所以这些字段不再收集。
|
||||
$sql = 'INSERT INTO trip_nodes
|
||||
(activity_id, node_type_id, node_category, original_plan_node_id,
|
||||
start_time, end_time, duration_minutes,
|
||||
start_location, end_location,
|
||||
title, description, travel_mode_id, vehicle_id,
|
||||
line_name, carrier, trip_number, seat_info, gate, status, cancel_reason)
|
||||
trip_number, status, cancel_reason)
|
||||
VALUES
|
||||
(:activity_id, :node_type_id, :node_category, :original_plan_node_id,
|
||||
:start_time, :end_time, :duration_minutes,
|
||||
:start_location, :end_location,
|
||||
:title, :description, :travel_mode_id, :vehicle_id,
|
||||
:line_name, :carrier, :trip_number, :seat_info, :gate, :status, :cancel_reason)';
|
||||
:trip_number, :status, :cancel_reason)';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
@@ -106,8 +109,7 @@ class NodeController
|
||||
start_location = :start_location, end_location = :end_location,
|
||||
title = :title, description = :description,
|
||||
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
|
||||
line_name = :line_name, carrier = :carrier, trip_number = :trip_number,
|
||||
seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason
|
||||
trip_number = :trip_number, status = :status, cancel_reason = :cancel_reason
|
||||
WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
@@ -174,8 +176,8 @@ class NodeController
|
||||
start_time, end_time, duration_minutes,
|
||||
start_location, end_location,
|
||||
title, description, travel_mode_id, vehicle_id,
|
||||
line_name, carrier, trip_number, seat_info, gate, status)
|
||||
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
|
||||
trip_number, status)
|
||||
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
|
||||
$stmt->execute([
|
||||
(int)$plan['activity_id'],
|
||||
(int)$plan['node_type_id'],
|
||||
@@ -189,11 +191,7 @@ class NodeController
|
||||
$plan['description'],
|
||||
$plan['travel_mode_id'],
|
||||
$plan['vehicle_id'],
|
||||
$plan['line_name'],
|
||||
$plan['carrier'],
|
||||
$plan['trip_number'],
|
||||
$plan['seat_info'],
|
||||
$plan['gate'],
|
||||
]);
|
||||
|
||||
flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况');
|
||||
@@ -207,14 +205,8 @@ class NodeController
|
||||
|
||||
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
|
||||
|
||||
// 软关联的附件先查出来,删完数据再删文件
|
||||
$stmt = $db->prepare('SELECT file_url FROM attachments
|
||||
WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
$files = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
// 附件是软关联:交给 Attachments 统一清(同时会删本地文件或 OSS 对象)
|
||||
Attachments::purgeTarget(Attachments::TARGET_NODE, (int)$node['id']);
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
@@ -223,10 +215,6 @@ class NodeController
|
||||
$stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?');
|
||||
$stmt->execute([(int)$node['id']]);
|
||||
|
||||
foreach ($files as $url) {
|
||||
$this->deleteAttachmentFile((string)$url);
|
||||
}
|
||||
|
||||
flash('error', '节点已删除');
|
||||
redirect('activities/show', ['id' => (int)$node['activity_id']]);
|
||||
}
|
||||
@@ -249,6 +237,7 @@ class NodeController
|
||||
|
||||
// 出行方式(连同车辆信息拼成一行展示文本)
|
||||
$travelModeLabel = '';
|
||||
$travelModeType = '';
|
||||
if (!empty($node['travel_mode_id'])) {
|
||||
$stmt = $db->prepare('
|
||||
SELECT tm.mode_type, v.plate_number, v.model_name
|
||||
@@ -264,18 +253,13 @@ class NodeController
|
||||
} elseif (!empty($tm['model_name'])) {
|
||||
$label .= ' · ' . $tm['model_name'];
|
||||
}
|
||||
// 线路名属于节点自身(trip_nodes.line_name),不是出行方式表的列
|
||||
if (!empty($node['line_name'])) {
|
||||
$label .= ' · ' . $node['line_name'];
|
||||
}
|
||||
$travelModeLabel = $label;
|
||||
$travelModeType = (string)$tm['mode_type'];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM attachments
|
||||
WHERE target_type = "trip_node" AND target_id = ?');
|
||||
$stmt->execute([$nodeId]);
|
||||
$attachmentCount = (int)$stmt->fetchColumn();
|
||||
$nodeAttachments = Attachments::forTarget(Attachments::TARGET_NODE, $nodeId);
|
||||
$attachmentCount = count($nodeAttachments);
|
||||
|
||||
$familyId = (int)$node['family_id'];
|
||||
|
||||
@@ -284,8 +268,10 @@ class NodeController
|
||||
'node_type_name' => (string)$type['name'],
|
||||
'node_type_icon' => (string)$type['icon'],
|
||||
'travel_mode_label' => $travelModeLabel,
|
||||
'travel_mode_type' => $travelModeType,
|
||||
]),
|
||||
'attachmentCount' => $attachmentCount,
|
||||
'attachments' => $nodeAttachments,
|
||||
'members' => Members::forNode($nodeId),
|
||||
'familyUsers' => Members::familyUsers($familyId),
|
||||
'roleTags' => Members::roleTagMap($familyId),
|
||||
@@ -414,11 +400,11 @@ class NodeController
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'travel_mode_id' => $travelModeId,
|
||||
'vehicle_id' => $vehicleId,
|
||||
'line_name' => $this->strOrNull('line_name'),
|
||||
'carrier' => $this->strOrNull('carrier'),
|
||||
'line_name' => null,
|
||||
'carrier' => null,
|
||||
'trip_number' => $this->strOrNull('trip_number'),
|
||||
'seat_info' => $this->strOrNull('seat_info'),
|
||||
'gate' => $this->strOrNull('gate'),
|
||||
'seat_info' => null,
|
||||
'gate' => null,
|
||||
'status' => $status,
|
||||
'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null,
|
||||
];
|
||||
@@ -473,15 +459,6 @@ class NodeController
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function deleteAttachmentFile(string $url): void
|
||||
{
|
||||
if (strpos($url, '/uploads/attachments/') !== 0 || strpos($url, '..') !== false) {
|
||||
return;
|
||||
}
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
}
|
||||
|
||||
private function calcDuration(string $start, string $end): int
|
||||
{
|
||||
$s = strtotime($start);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Database;
|
||||
use App\Migrate;
|
||||
use App\Oss;
|
||||
use App\Settings;
|
||||
use App\Storage;
|
||||
|
||||
/**
|
||||
* 存储设置(仅管理员)。
|
||||
*
|
||||
* 支持两种后端:本站服务器(默认)/ 阿里云 OSS;
|
||||
* OSS 又支持两种鉴权:ECS 实例 RAM 角色换 STS 临时凭证(推荐,无需落库长期密钥)、
|
||||
* 或直接填 AccessKey。
|
||||
*/
|
||||
class SettingsController
|
||||
{
|
||||
private const DRIVERS = ['local', 'oss'];
|
||||
private const AUTHS = ['ecs_role', 'access_key'];
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
|
||||
$s = Settings::all();
|
||||
|
||||
// 只探测一次:元数据服务不可达时每次调用都要等超时,别在同一个请求里问两遍
|
||||
$ecsRoles = Oss::listEcsRoles();
|
||||
|
||||
view('settings/index', [
|
||||
's' => $s,
|
||||
'tableExists' => Settings::tableExists(),
|
||||
'schemaNow' => Migrate::currentVersion(Database::get()),
|
||||
'schemaTarget' => Migrate::VERSION,
|
||||
'driver' => Storage::driver(),
|
||||
'misconfigured' => Storage::ossMisconfigured(),
|
||||
'ecsRoles' => $ecsRoles,
|
||||
'isEcs' => $ecsRoles !== [],
|
||||
'pageTitle' => '存储设置',
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
csrf_check();
|
||||
|
||||
$driver = (string)($_POST['storage_driver'] ?? 'local');
|
||||
if (!in_array($driver, self::DRIVERS, true)) {
|
||||
$driver = 'local';
|
||||
}
|
||||
$auth = (string)($_POST['oss_auth'] ?? 'ecs_role');
|
||||
if (!in_array($auth, self::AUTHS, true)) {
|
||||
$auth = 'ecs_role';
|
||||
}
|
||||
|
||||
$pairs = [
|
||||
'storage_driver' => $driver,
|
||||
'oss_endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')),
|
||||
'oss_bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63),
|
||||
'oss_prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')),
|
||||
'oss_auth' => $auth,
|
||||
'oss_role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64),
|
||||
'oss_ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128),
|
||||
'oss_internal' => empty($_POST['oss_internal']) ? '0' : '1',
|
||||
'photo_max_mb' => (string)max(1, min(50, (int)($_POST['photo_max_mb'] ?? 12))),
|
||||
];
|
||||
|
||||
// 密钥留空 = 不修改(避免管理员每次保存都要重新粘贴一遍)
|
||||
$newSecret = trim((string)($_POST['oss_ak_secret'] ?? ''));
|
||||
if ($newSecret !== '') {
|
||||
$pairs['oss_ak_secret'] = $newSecret;
|
||||
} elseif (!Settings::tableExists() || Settings::get('oss_ak_secret') === '') {
|
||||
$pairs['oss_ak_secret'] = '';
|
||||
}
|
||||
|
||||
if ($driver === 'oss') {
|
||||
$err = $this->validateOss($pairs, $auth);
|
||||
if ($err !== null) {
|
||||
flash('error', $err);
|
||||
redirect('settings/index');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Settings::put($pairs);
|
||||
flash('error', '设置已保存。当前生效的存储方式:' . Storage::label(Storage::driver()));
|
||||
} catch (\Throwable $e) {
|
||||
flash('error', '保存失败:' . $e->getMessage()
|
||||
. '(若提示表不存在,请先在服务器上执行 php tools/migrate.php)');
|
||||
}
|
||||
redirect('settings/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接。**用表单里当前填的值测**,所以可以先测通再保存。
|
||||
* 返回 JSON,供前端按钮就地显示结果。
|
||||
*/
|
||||
public function test(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
|
||||
http_response_code(419);
|
||||
echo json_encode(['ok' => false, 'message' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$auth = (string)($_POST['oss_auth'] ?? 'ecs_role');
|
||||
$secret = trim((string)($_POST['oss_ak_secret'] ?? ''));
|
||||
if ($secret === '') {
|
||||
// 前端没填新密钥 → 用已保存的那把
|
||||
$secret = (string)Settings::get('oss_ak_secret');
|
||||
}
|
||||
|
||||
$cfg = [
|
||||
'endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')),
|
||||
'bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63),
|
||||
'prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')),
|
||||
'auth' => in_array($auth, self::AUTHS, true) ? $auth : 'ecs_role',
|
||||
'role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64),
|
||||
'ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128),
|
||||
'ak_secret' => $secret,
|
||||
'internal' => !empty($_POST['oss_internal']),
|
||||
];
|
||||
|
||||
if ($cfg['endpoint'] === '' || $cfg['bucket'] === '') {
|
||||
echo json_encode(['ok' => false, 'message' => '请先填写 Endpoint 和 Bucket'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new Oss($cfg))->testRoundTrip();
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['ok' => false, 'message' => $e->getMessage(), 'steps' => []];
|
||||
}
|
||||
|
||||
$result['endpointResolved'] = (new Oss($cfg))->endpoint();
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** 列出该 ECS 实例可用的 RAM 角色,供前端一键填入 */
|
||||
public function roles(): void
|
||||
{
|
||||
Access::requireAdmin();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['roles' => Oss::listEcsRoles()], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────── 清洗与校验
|
||||
|
||||
private function validateOss(array $pairs, string $auth): ?string
|
||||
{
|
||||
if ($pairs['oss_endpoint'] === '') {
|
||||
return '请填写 OSS Endpoint(例如 oss-cn-qingdao.aliyuncs.com)';
|
||||
}
|
||||
if ($pairs['oss_bucket'] === '') {
|
||||
return '请填写 Bucket 名称';
|
||||
}
|
||||
if (strpos($pairs['oss_endpoint'], 'aliyuncs.com') === false
|
||||
&& strpos($pairs['oss_endpoint'], '.') === false) {
|
||||
return 'Endpoint 看起来不像域名,请检查';
|
||||
}
|
||||
if ($auth === 'ecs_role' && $pairs['oss_role_name'] === '') {
|
||||
return '选择 ECS 角色鉴权时,必须填写角色名(可点「检测可用角色」自动获取)';
|
||||
}
|
||||
if ($auth === 'access_key') {
|
||||
$hasId = $pairs['oss_ak_id'] !== '';
|
||||
$hasSecret = ($pairs['oss_ak_secret'] ?? '') !== '' || (string)Settings::get('oss_ak_secret') !== '';
|
||||
if (!$hasId || !$hasSecret) {
|
||||
return '选择 AccessKey 鉴权时,必须填写 AccessKeyId 与 AccessKeySecret';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** endpoint 统一成纯域名(容忍用户粘贴带 https:// 的完整地址) */
|
||||
private function cleanEndpoint(string $v): string
|
||||
{
|
||||
$v = strtolower(trim($v));
|
||||
$v = preg_replace('#^https?://#', '', $v) ?? $v;
|
||||
$v = rtrim($v, '/');
|
||||
$v = preg_replace('/[^a-z0-9.\-]/', '', $v) ?? $v;
|
||||
return $v;
|
||||
}
|
||||
|
||||
private function cleanToken(string $v, int $max): string
|
||||
{
|
||||
$v = trim($v);
|
||||
$v = preg_replace('/[^A-Za-z0-9._\-]/', '', $v) ?? $v;
|
||||
return mb_substr($v, 0, $max);
|
||||
}
|
||||
|
||||
private function cleanPrefix(string $v): string
|
||||
{
|
||||
$v = trim(str_replace('\\', '/', $v));
|
||||
$v = ltrim($v, '/');
|
||||
$v = preg_replace('#[^A-Za-z0-9._\-/]#', '', $v) ?? $v;
|
||||
$v = str_replace('..', '', $v) ?? $v;
|
||||
if ($v !== '' && substr($v, -1) !== '/') {
|
||||
$v .= '/';
|
||||
}
|
||||
return mb_substr($v, 0, 200);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
||||
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
use App\Mime;
|
||||
|
||||
class UserController
|
||||
{
|
||||
@@ -353,7 +354,6 @@ class UserController
|
||||
$ext = strtolower($m[1]);
|
||||
if ($ext === 'jpeg') $ext = 'jpg';
|
||||
$raw = base64_decode($m[2], true);
|
||||
|
||||
if ($raw === false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE);
|
||||
@@ -366,9 +366,9 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $raw);
|
||||
finfo_close($finfo);
|
||||
// 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底)
|
||||
$info = Mime::detectBuffer($raw);
|
||||
$mime = $info['mime'] ?? '';
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
|
||||
@@ -378,7 +378,8 @@ class UserController
|
||||
$dir = dirname(__DIR__, 2) . '/uploads/avatars';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
// 扩展名以「实际识别出的类型」为准,别信前端给的
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . (Mime::extFor($mime) ?? $ext);
|
||||
$savePath = $dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($savePath, $raw) === false) {
|
||||
|
||||
Reference in New Issue
Block a user