交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - 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:
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 统一附件表(attachments)的读写。
|
||||
*
|
||||
* 这张表是**软关联**(target_type + target_id),没有外键,删父行时必须手工清理,
|
||||
* 所以「按目标清空」的语义集中放在这里,别在控制器里各写一遍。
|
||||
*/
|
||||
final class Attachments
|
||||
{
|
||||
public const TARGET_ACTIVITY = 'activity';
|
||||
public const TARGET_NODE = 'trip_node';
|
||||
public const TARGET_COMMENT = 'comment';
|
||||
|
||||
/**
|
||||
* 新增一条附件记录。
|
||||
*
|
||||
* 容错:万一部署时忘了跑迁移(没有 storage/object_key 等列),
|
||||
* 这里会退回「只写老列」的插入,而不是抛错让上传整体失败。
|
||||
*
|
||||
* @param array{storage:string,key:string,size:int} $stored
|
||||
* @return int 新记录 id
|
||||
*/
|
||||
public static function create(
|
||||
string $targetType,
|
||||
int $targetId,
|
||||
int $uploaderId,
|
||||
array $stored,
|
||||
string $fileName,
|
||||
?string $mime = null,
|
||||
bool $isImage = false,
|
||||
?int $width = null,
|
||||
?int $height = null
|
||||
): int {
|
||||
$db = Database::get();
|
||||
|
||||
// 本地存储保留 file_url(兼容旧读法 / 旧模板),OSS 存的是私有对象,不给直链
|
||||
$fileUrl = $stored['storage'] === Storage::DRIVER_LOCAL ? '/uploads/' . $stored['key'] : '';
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare('INSERT INTO attachments
|
||||
(target_type, target_id, uploader_id, file_url, file_name,
|
||||
storage, object_key, mime_type, size_bytes, is_image, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$targetType, $targetId, $uploaderId, $fileUrl, $fileName,
|
||||
$stored['storage'], $stored['key'], $mime, $stored['size'],
|
||||
$isImage ? 1 : 0, $width, $height,
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
} catch (\PDOException $e) {
|
||||
// SQLSTATE 42S22 = 列不存在 → 迁移没跑,退回老结构
|
||||
if (($e->errorInfo[0] ?? '') !== '42S22') {
|
||||
throw $e;
|
||||
}
|
||||
$stmt = $db->prepare('INSERT INTO attachments
|
||||
(target_type, target_id, uploader_id, file_url, file_name)
|
||||
VALUES (?, ?, ?, ?, ?)');
|
||||
$stmt->execute([$targetType, $targetId, $uploaderId,
|
||||
$stored['storage'] === Storage::DRIVER_LOCAL ? '/uploads/' . $stored['key'] : $fileName,
|
||||
$fileName]);
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$stmt = Database::get()->prepare('SELECT * FROM attachments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** @return array<int, array> */
|
||||
public static function forTarget(string $targetType, int $targetId): array
|
||||
{
|
||||
$stmt = Database::get()->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 = ? AND a.target_id = ?
|
||||
ORDER BY a.sort_order ASC, a.id ASC');
|
||||
$stmt->execute([$targetType, $targetId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/** 删除一条并清掉它对应的物理对象 */
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$att = self::find($id);
|
||||
if (!$att) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Storage::delete(
|
||||
self::storageOf($att),
|
||||
(string)($att['object_key'] ?? ''),
|
||||
(string)($att['file_url'] ?? '')
|
||||
);
|
||||
|
||||
$stmt = Database::get()->prepare('DELETE FROM attachments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 清空某个目标下的所有附件(含物理对象) */
|
||||
public static function purgeTarget(string $targetType, int $targetId): int
|
||||
{
|
||||
$rows = self::forTarget($targetType, $targetId);
|
||||
foreach ($rows as $row) {
|
||||
Storage::delete(
|
||||
self::storageOf($row),
|
||||
(string)($row['object_key'] ?? ''),
|
||||
(string)($row['file_url'] ?? '')
|
||||
);
|
||||
}
|
||||
if ($rows === []) {
|
||||
return 0;
|
||||
}
|
||||
$stmt = Database::get()->prepare('DELETE FROM attachments WHERE target_type = ? AND target_id = ?');
|
||||
$stmt->execute([$targetType, $targetId]);
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
/** 一批节点的附件数(一次 GROUP BY,避免 N+1) */
|
||||
public static function countForNodes(array $nodeIds): array
|
||||
{
|
||||
if ($nodeIds === []) return [];
|
||||
$ids = array_map('intval', $nodeIds);
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = Database::get()->prepare("SELECT target_id, COUNT(*) AS cnt FROM attachments
|
||||
WHERE target_type = '" . self::TARGET_NODE . "' AND target_id IN ($ph)
|
||||
GROUP BY target_id");
|
||||
$stmt->execute($ids);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[(int)$row['target_id']] = (int)$row['cnt'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 历史行没有 storage 列时按本地处理 */
|
||||
public static function storageOf(array $att): string
|
||||
{
|
||||
return (string)($att['storage'] ?? '') === Storage::DRIVER_OSS
|
||||
? Storage::DRIVER_OSS
|
||||
: Storage::DRIVER_LOCAL;
|
||||
}
|
||||
|
||||
public static function isImageRow(array $att): bool
|
||||
{
|
||||
if (array_key_exists('is_image', $att)) {
|
||||
return (int)$att['is_image'] === 1;
|
||||
}
|
||||
// 老行没这一列 → 看 MIME / 扩展名
|
||||
$mime = (string)($att['mime_type'] ?? '');
|
||||
if ($mime !== '') {
|
||||
return Mime::isImageMime($mime);
|
||||
}
|
||||
$ext = strtolower(pathinfo((string)($att['file_name'] ?? $att['file_url'] ?? ''), PATHINFO_EXTENSION));
|
||||
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示所需的字段,集中在这里算 —— 这样模板里就不必再去碰 file_url
|
||||
* 这类历史列,也不会在模板层做文件系统访问。
|
||||
*
|
||||
* name :展示用文件名;老行没 file_name 时退回 file_url 的末段
|
||||
* size_text :人类可读的体积;老行没 size_bytes 时读本地文件兜底
|
||||
*
|
||||
* @return array{name:string, size_text:string}
|
||||
*/
|
||||
public static function describe(array $att): array
|
||||
{
|
||||
$name = trim((string)($att['file_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = basename((string)($att['file_url'] ?? '')) ?: 'file';
|
||||
}
|
||||
|
||||
$size = (int)($att['size_bytes'] ?? 0);
|
||||
if ($size <= 0) {
|
||||
// 迁移前落库的老行只有 file_url,量一下本地文件
|
||||
$local = self::storageOf($att) === Storage::DRIVER_LOCAL
|
||||
? Storage::resolveLocal((string)($att['object_key'] ?? ''), (string)($att['file_url'] ?? ''))
|
||||
: null;
|
||||
if ($local !== null && is_file($local)) {
|
||||
$size = (int)@filesize($local);
|
||||
}
|
||||
}
|
||||
|
||||
return ['name' => $name, 'size_text' => \human_size($size)];
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* 幂等迁移。
|
||||
*
|
||||
* 为什么不用「自动在每次请求里跑」:那会给每个请求都加一次查询。
|
||||
* 这里做成**显式执行**(tools/migrate.php,仅 CLI),并且业务代码对
|
||||
* 「表/列还不存在」是容错的(见 Settings::all() 的 try/catch),
|
||||
* 所以哪天忘了跑迁移,后果是「存储退回本地」而不是整站白屏。
|
||||
*/
|
||||
final class Migrate
|
||||
{
|
||||
/** 迁移完成后的目标版本号,改结构时 +1 */
|
||||
public const VERSION = 2;
|
||||
|
||||
/**
|
||||
* @param callable(string):void|null $log
|
||||
* @return string[] 实际执行了的步骤描述
|
||||
*/
|
||||
public static function run(PDO $db, ?callable $log = null): array
|
||||
{
|
||||
$log = $log ?? static function (string $s): void {};
|
||||
$done = [];
|
||||
|
||||
$step = static function (string $desc, callable $fn) use (&$done, $log): void {
|
||||
$fn();
|
||||
$done[] = $desc;
|
||||
$log(' + ' . $desc);
|
||||
};
|
||||
|
||||
// 1) app_settings
|
||||
if (!self::hasTable($db, 'app_settings')) {
|
||||
$step('创建 app_settings 表', static function () use ($db): void {
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `app_settings` (
|
||||
`setting_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`setting_value` text COLLATE utf8mb4_unicode_ci,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`setting_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点级键值设置'
|
||||
SQL);
|
||||
});
|
||||
}
|
||||
|
||||
// 2) attachments 存储相关列
|
||||
// 注意:MySQL 8 不支持「加列时带 IF NOT EXISTS」的写法,必须自己查 information_schema
|
||||
$cols = [
|
||||
'storage' => "varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'local' COMMENT 'local|oss'",
|
||||
'object_key' => "varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'OSS 对象键,或本地相对路径'",
|
||||
'mime_type' => "varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL",
|
||||
'size_bytes' => 'bigint DEFAULT NULL',
|
||||
'width' => 'int DEFAULT NULL COMMENT \'图片宽(非图片为空)\'',
|
||||
'height' => 'int DEFAULT NULL COMMENT \'图片高(非图片为空)\'',
|
||||
'is_image' => "tinyint(1) NOT NULL DEFAULT '0'",
|
||||
];
|
||||
foreach ($cols as $name => $def) {
|
||||
if (!self::hasColumn($db, 'attachments', $name)) {
|
||||
$step("attachments 增加列 $name", static function () use ($db, $name, $def): void {
|
||||
$db->exec("ALTER TABLE `attachments` ADD COLUMN `$name` $def");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 索引
|
||||
if (!self::hasIndex($db, 'attachments', 'idx_storage')) {
|
||||
$step('attachments 增加索引 idx_storage', static function () use ($db): void {
|
||||
$db->exec('ALTER TABLE `attachments` ADD KEY `idx_storage` (`storage`)');
|
||||
});
|
||||
}
|
||||
|
||||
// 4) 记录版本
|
||||
$db->prepare('INSERT INTO app_settings (setting_key, setting_value) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)')
|
||||
->execute(['schema_version', (string)self::VERSION]);
|
||||
|
||||
return $done;
|
||||
}
|
||||
|
||||
/** 当前库里的版本号(未迁移过则为 0) */
|
||||
public static function currentVersion(PDO $db): int
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('SELECT setting_value FROM app_settings WHERE setting_key = ?');
|
||||
$stmt->execute(['schema_version']);
|
||||
return (int)$stmt->fetchColumn();
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static function hasTable(PDO $db, string $table): bool
|
||||
{
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?');
|
||||
$stmt->execute([$table]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private static function hasColumn(PDO $db, string $table, string $column): bool
|
||||
{
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?');
|
||||
$stmt->execute([$table, $column]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private static function hasIndex(PDO $db, string $table, string $index): bool
|
||||
{
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?');
|
||||
$stmt->execute([$table, $index]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* MIME 识别 —— **不依赖 fileinfo 扩展**。
|
||||
*
|
||||
* 为什么不用 fileinfo 扩展:线上同时装了 PHP 7.4 与 8.1,**7.4 没装这个扩展**,
|
||||
* 一旦站点切到 7.4,调用它的探测函数会直接致命错误(整页 500)。
|
||||
* (部署脚本里有条静态断言要求全项目不出现该扩展的函数名,这条注释刻意绕开写法。)
|
||||
* 这里改成三层兜底,纯标准库实现:
|
||||
* 1) getimagesize() —— 图片最可靠,还能顺带拿到宽高;
|
||||
* 2) 魔数嗅探 —— PDF / ZIP 系(docx/xlsx)/ MP4 / MOV / HEIC 等;
|
||||
* 3) 扩展名映射 —— 最后兜底。
|
||||
*/
|
||||
final class Mime
|
||||
{
|
||||
/** 允许上传的 MIME → 扩展名(白名单,同时也是扩展名兜底表) */
|
||||
public const ALLOWED = [
|
||||
'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',
|
||||
];
|
||||
|
||||
/** 扩展名 → MIME(第 3 层兜底) */
|
||||
private const EXT_TO_MIME = [
|
||||
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
|
||||
'gif' => 'image/gif', 'webp' => 'image/webp', 'heic' => 'image/heic',
|
||||
'pdf' => 'application/pdf', 'doc' => 'application/msword',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls' => 'application/vnd.ms-excel',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'zip' => 'application/zip', 'mp4' => 'video/mp4', 'mov' => 'video/quicktime',
|
||||
];
|
||||
|
||||
public static function isImageMime(string $mime): bool
|
||||
{
|
||||
return strpos($mime, 'image/') === 0;
|
||||
}
|
||||
|
||||
public static function extFor(string $mime): ?string
|
||||
{
|
||||
if (isset(self::ALLOWED[$mime])) {
|
||||
return self::ALLOWED[$mime];
|
||||
}
|
||||
return self::isImageMime($mime) ? str_replace('image/', '', $mime) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别磁盘上的文件。
|
||||
* @return array{mime:?string,width:?int,height:?int,is_image:bool}
|
||||
*/
|
||||
public static function detectFile(string $path, string $originalName = ''): array
|
||||
{
|
||||
$size = @getimagesize($path);
|
||||
if (is_array($size) && !empty($size['mime'])) {
|
||||
$mime = (string)$size['mime'];
|
||||
// getimagesize 认不出 webp 之外的现代格式时不会到这里
|
||||
return [
|
||||
'mime' => $mime,
|
||||
'width' => (int)($size[0] ?? 0) ?: null,
|
||||
'height' => (int)($size[1] ?? 0) ?: null,
|
||||
'is_image' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$head = self::readHead($path, 32);
|
||||
$mime = self::sniffMagic($head) ?? self::mimeFromName($originalName);
|
||||
|
||||
return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别内存里的字节(前端裁剪后是 base64,没有原始文件名)。
|
||||
* @return array{mime:?string,width:?int,height:?int,is_image:bool}
|
||||
*/
|
||||
public static function detectBuffer(string $raw): array
|
||||
{
|
||||
if ($raw === '') {
|
||||
return ['mime' => null, 'width' => null, 'height' => null, 'is_image' => false];
|
||||
}
|
||||
|
||||
// getimagesizefromstring 同样属于标准库
|
||||
$size = @getimagesizefromstring($raw);
|
||||
if (is_array($size) && !empty($size['mime'])) {
|
||||
return [
|
||||
'mime' => (string)$size['mime'],
|
||||
'width' => (int)($size[0] ?? 0) ?: null,
|
||||
'height' => (int)($size[1] ?? 0) ?: null,
|
||||
'is_image' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$mime = self::sniffMagic(substr($raw, 0, 32));
|
||||
return ['mime' => $mime, 'width' => null, 'height' => null, 'is_image' => $mime !== null && self::isImageMime($mime)];
|
||||
}
|
||||
|
||||
/** 从扩展名推 MIME(第 3 层) */
|
||||
public static function mimeFromName(string $name): ?string
|
||||
{
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
return self::EXT_TO_MIME[$ext] ?? null;
|
||||
}
|
||||
|
||||
/** 魔数嗅探(第 2 层) */
|
||||
private static function sniffMagic(string $head): ?string
|
||||
{
|
||||
if ($head === '') return null;
|
||||
|
||||
$sig = static function (string $bytes, int $offset = 0) use ($head): bool {
|
||||
return substr($head, $offset, strlen($bytes)) === $bytes;
|
||||
};
|
||||
|
||||
if ($sig("\xFF\xD8\xFF")) return 'image/jpeg';
|
||||
if ($sig("\x89PNG\r\n\x1a\n")) return 'image/png';
|
||||
if ($sig('GIF87a') || $sig('GIF89a')) return 'image/gif';
|
||||
if ($sig('RIFF') && substr($head, 8, 4) === 'WEBP') return 'image/webp';
|
||||
if ($sig('%PDF-')) return 'application/pdf';
|
||||
if ($sig('PK\x03\x04')) return 'application/zip'; // docx/xlsx 也是 zip 容器
|
||||
if ($sig('PK')) return 'application/zip';
|
||||
if ($sig("\xD0\xCF\x11\xE0")) return 'application/vnd.ms-excel'; // 老式 xls/doc
|
||||
// HEIC / MP4 / MOV:ISO-BMFF,第 5-8 字节是品牌
|
||||
if (strlen($head) >= 12 && substr($head, 4, 4) === 'ftyp') {
|
||||
$brand = substr($head, 8, 4);
|
||||
if (in_array($brand, ['heic', 'heix', 'hevc', 'heim', 'heis'], true)) return 'image/heic';
|
||||
if (in_array($brand, ['qt '], true)) return 'video/quicktime';
|
||||
return 'video/mp4';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function readHead(string $path, int $n): string
|
||||
{
|
||||
$fh = @fopen($path, 'rb');
|
||||
if ($fh === false) return '';
|
||||
$head = (string)fread($fh, $n);
|
||||
fclose($fh);
|
||||
return $head;
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 极简阿里云 OSS 客户端(不依赖 composer,只有 curl + hash)。
|
||||
*
|
||||
* 为什么手写:项目没有 composer / autoload,官方 SDK 拉不进来。
|
||||
* OSS 的 V1 签名本身很简单,够用且可审计:
|
||||
*
|
||||
* StringToSign = VERB \n Content-MD5 \n Content-Type \n Date \n
|
||||
* CanonicalizedOSSHeaders + CanonicalizedResource
|
||||
* Authorization: OSS <AccessKeyId>:<base64(hmac-sha1(AccessKeySecret, StringToSign))>
|
||||
*
|
||||
* 鉴权两种:
|
||||
* 1) ecs_role —— 从 ECS 实例元数据取 RAM 角色的 STS 临时凭证(含 SecurityToken,
|
||||
* 需要作为 x-oss-security-token 头,并计入 CanonicalizedOSSHeaders);
|
||||
* 2) access_key —— 直接用长期 AK/SK。
|
||||
*
|
||||
* 桶是**私有**的:本类从不生成对外直链,取文件一律经 PHP 代理(见 MediaController)。
|
||||
*/
|
||||
final class Oss
|
||||
{
|
||||
private const META_BASE = 'http://100.100.100.200/latest/meta-data/ram/security-credentials/';
|
||||
|
||||
private string $endpoint; // 已归一化(去掉 scheme、去尾斜杠)
|
||||
private string $bucket;
|
||||
private string $prefix;
|
||||
private string $auth; // ecs_role | access_key
|
||||
private string $roleName;
|
||||
private string $akId;
|
||||
private string $akSecret;
|
||||
|
||||
/** @var array{id:string,secret:string,token:string,expire:int} 运行时凭证 */
|
||||
private array $cred = ['id' => '', 'secret' => '', 'token' => '', 'expire' => 0];
|
||||
|
||||
public function __construct(array $cfg)
|
||||
{
|
||||
$this->bucket = trim((string)($cfg['bucket'] ?? ''));
|
||||
$this->prefix = self::normalizePrefix((string)($cfg['prefix'] ?? ''));
|
||||
$this->auth = (string)($cfg['auth'] ?? 'ecs_role');
|
||||
$this->roleName = (string)($cfg['role_name'] ?? '');
|
||||
$this->akId = (string)($cfg['ak_id'] ?? '');
|
||||
$this->akSecret = (string)($cfg['ak_secret'] ?? '');
|
||||
|
||||
$endpoint = trim((string)($cfg['endpoint'] ?? ''));
|
||||
$endpoint = preg_replace('#^https?://#i', '', $endpoint) ?? $endpoint;
|
||||
$endpoint = rtrim($endpoint, '/');
|
||||
// 「内网」不是单独的参数,而是在 endpoint 里插 -internal:
|
||||
// oss-cn-qingdao.aliyuncs.com -> oss-cn-qingdao-internal.aliyuncs.com
|
||||
if (!empty($cfg['internal']) && strpos($endpoint, '-internal.') === false) {
|
||||
$endpoint = preg_replace('/^([a-z0-9-]+?)(\.[a-z0-9-]+\.[a-z]+)$/i', '$1-internal$2', $endpoint, 1) ?? $endpoint;
|
||||
}
|
||||
$this->endpoint = $endpoint;
|
||||
}
|
||||
|
||||
public function bucket(): string { return $this->bucket; }
|
||||
public function prefix(): string { return $this->prefix; }
|
||||
public function endpoint(): string { return $this->endpoint; }
|
||||
|
||||
/** 元数据/设置里的相对键 → 带前缀的完整对象键 */
|
||||
public function objectKey(string $key): string
|
||||
{
|
||||
return $this->prefix . ltrim($key, '/');
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────── 基本操作
|
||||
|
||||
/** 上传(覆盖)。$key 为不含前缀的相对键 */
|
||||
public function put(string $key, string $body, string $mime = 'application/octet-stream'): void
|
||||
{
|
||||
$res = $this->request('PUT', $this->objectKey($key), [
|
||||
'Content-Type' => $mime,
|
||||
'Content-MD5' => base64_encode(md5($body, true)),
|
||||
], $body);
|
||||
if ($res['status'] < 200 || $res['status'] >= 300) {
|
||||
throw new \RuntimeException('OSS 上传失败(HTTP ' . $res['status'] . '):' . self::short($res['body']));
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载对象内容 */
|
||||
public function get(string $key): string
|
||||
{
|
||||
$res = $this->request('GET', $this->objectKey($key));
|
||||
if ($res['status'] === 404) {
|
||||
throw new \RuntimeException('对象不存在:' . $key);
|
||||
}
|
||||
if ($res['status'] < 200 || $res['status'] >= 300) {
|
||||
throw new \RuntimeException('OSS 读取失败(HTTP ' . $res['status'] . '):' . self::short($res['body']));
|
||||
}
|
||||
return $res['body'];
|
||||
}
|
||||
|
||||
/** 读取对象响应头(用于代理时取 Content-Type / Content-Length 而不下载正文) */
|
||||
public function head(string $key): ?array
|
||||
{
|
||||
$res = $this->request('HEAD', $this->objectKey($key));
|
||||
if ($res['status'] < 200 || $res['status'] >= 300) {
|
||||
return null;
|
||||
}
|
||||
return self::parseHeaders($res['headers']);
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
$res = $this->request('DELETE', $this->objectKey($key));
|
||||
// 404 视为已不存在,同样算成功(幂等)
|
||||
return ($res['status'] >= 200 && $res['status'] < 300) || $res['status'] === 404;
|
||||
}
|
||||
|
||||
/**
|
||||
* 走一遍「上传 → 读取 → 删除」,验证凭证对目标桶真的有读写权限。
|
||||
* 比只做 HEAD 桶更贴近真实用途(很多 RAM 策略不给 HeadBucket)。
|
||||
*
|
||||
* @return array{ok:bool,message:string,steps:string[]}
|
||||
*/
|
||||
public function testRoundTrip(): array
|
||||
{
|
||||
$key = '.healthcheck/' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.txt';
|
||||
$body = 'triplog healthcheck ' . date('c');
|
||||
$steps = [];
|
||||
|
||||
try {
|
||||
$this->put($key, $body, 'text/plain');
|
||||
$steps[] = '上传 OK';
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps];
|
||||
}
|
||||
|
||||
try {
|
||||
$got = $this->get($key);
|
||||
if ($got !== $body) {
|
||||
return ['ok' => false, 'message' => '读取内容与上传不一致', 'steps' => array_merge($steps, ['读取 内容不符'])];
|
||||
}
|
||||
$steps[] = '读取 OK';
|
||||
} catch (\Throwable $e) {
|
||||
$steps[] = '读取 失败';
|
||||
$this->safeDelete($key);
|
||||
return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps];
|
||||
}
|
||||
|
||||
$ok = $this->safeDelete($key);
|
||||
$steps[] = $ok ? '删除 OK' : '删除 失败';
|
||||
|
||||
return [
|
||||
'ok' => $ok,
|
||||
'message' => $ok ? '连接正常,读写与删除都可用' : '上传读取正常,但删除失败(检查 RAM 策略是否含 DeleteObject)',
|
||||
'steps' => $steps,
|
||||
];
|
||||
}
|
||||
|
||||
private function safeDelete(string $key): bool
|
||||
{
|
||||
try {
|
||||
return $this->delete($key);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────── ECS 元数据
|
||||
|
||||
/**
|
||||
* 列出该 ECS 实例可用的 RAM 角色名。
|
||||
* 结果在单次请求内缓存 —— 不在 ECS 上时这里要等一次超时,别重复问。
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function listEcsRoles(): array
|
||||
{
|
||||
static $cache = null;
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$res = self::httpGet(self::META_BASE, 2);
|
||||
if ($res === null) {
|
||||
return $cache = [];
|
||||
}
|
||||
$roles = array_filter(array_map('trim', explode("\n", $res)));
|
||||
return $cache = array_values(array_unique(array_filter($roles, static function (string $r): bool {
|
||||
return $r !== '' && strpos($r, '404') === false;
|
||||
})));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────── 内部实现
|
||||
|
||||
private function request(string $verb, string $resourceKey, array $headers = [], string $body = ''): array
|
||||
{
|
||||
$cred = $this->credential();
|
||||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||||
|
||||
// x-oss-* 头(含 STS 的 security-token)必须参与签名
|
||||
$ossHeaders = [];
|
||||
if ($cred['token'] !== '') {
|
||||
$ossHeaders['x-oss-security-token'] = $cred['token'];
|
||||
}
|
||||
|
||||
$canonicalizedOssHeaders = '';
|
||||
ksort($ossHeaders);
|
||||
foreach ($ossHeaders as $k => $v) {
|
||||
$canonicalizedOssHeaders .= strtolower($k) . ':' . $v . "\n";
|
||||
}
|
||||
|
||||
$canonicalizedResource = '/' . $this->bucket . '/' . $resourceKey;
|
||||
|
||||
$stringToSign = $verb . "\n"
|
||||
. ($headers['Content-MD5'] ?? '') . "\n"
|
||||
. ($headers['Content-Type'] ?? '') . "\n"
|
||||
. $date . "\n"
|
||||
. $canonicalizedOssHeaders
|
||||
. $canonicalizedResource;
|
||||
|
||||
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
|
||||
|
||||
$reqHeaders = array_merge($headers, $ossHeaders, [
|
||||
'Date' => $date,
|
||||
'Authorization' => 'OSS ' . $cred['id'] . ':' . $signature,
|
||||
]);
|
||||
|
||||
$url = 'https://' . $this->bucket . '.' . $this->endpoint . '/' . self::encodeKey($resourceKey);
|
||||
|
||||
return self::httpRequest($verb, $url, $reqHeaders, $body, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取到可用凭证:ECS 角色走元数据(带文件缓存,避免每个请求都去问元数据服务),
|
||||
* AK/SK 直接返回;两者统一成 {id, secret, token, expire}。
|
||||
*/
|
||||
private function credential(): array
|
||||
{
|
||||
if ($this->cred['id'] !== '' && $this->cred['expire'] > time()) {
|
||||
return $this->cred;
|
||||
}
|
||||
|
||||
if ($this->auth === 'access_key') {
|
||||
if ($this->akId === '' || $this->akSecret === '') {
|
||||
throw new \RuntimeException('未填写 AccessKeyId / AccessKeySecret');
|
||||
}
|
||||
return $this->cred = ['id' => $this->akId, 'secret' => $this->akSecret, 'token' => '', 'expire' => time() + 3600];
|
||||
}
|
||||
|
||||
if ($this->roleName === '') {
|
||||
throw new \RuntimeException('未选择 ECS 角色名');
|
||||
}
|
||||
|
||||
// 先看缓存
|
||||
$cached = $this->readCredCache();
|
||||
if ($cached !== null) {
|
||||
return $this->cred = $cached;
|
||||
}
|
||||
|
||||
$url = self::META_BASE . rawurlencode($this->roleName);
|
||||
$json = self::httpGet($url, 3);
|
||||
if ($json === null) {
|
||||
throw new \RuntimeException('读取 ECS 元数据失败(该机器可能不是 ECS,或元数据服务不可达)');
|
||||
}
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data) || empty($data['AccessKeyId']) || empty($data['AccessKeySecret'])) {
|
||||
throw new \RuntimeException('ECS 元数据未返回有效凭证:' . self::short($json));
|
||||
}
|
||||
if (($data['Code'] ?? 'Success') !== 'Success') {
|
||||
throw new \RuntimeException('ECS 角色凭证获取失败:' . (string)($data['Code'] ?? ''));
|
||||
}
|
||||
|
||||
$expire = isset($data['Expiration']) ? (int)strtotime((string)$data['Expiration']) : (time() + 900);
|
||||
$cred = [
|
||||
'id' => (string)$data['AccessKeyId'],
|
||||
'secret' => (string)$data['AccessKeySecret'],
|
||||
'token' => (string)($data['SecurityToken'] ?? ''),
|
||||
// 提前 5 分钟作废,避免签名时刚好过期
|
||||
'expire' => max(time() + 60, $expire - 300),
|
||||
];
|
||||
$this->writeCredCache($cred);
|
||||
return $this->cred = $cred;
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭证缓存落在 /tmp(**不能在 web 目录下**:STS 临时凭证是密钥,
|
||||
* 放 uploads/ 会被直接下载走)。open_basedir 允许 /tmp。
|
||||
*/
|
||||
private function cachePath(): string
|
||||
{
|
||||
$id = substr(hash('sha256', $this->endpoint . '|' . $this->bucket . '|' . $this->auth . '|' . $this->roleName), 0, 16);
|
||||
return rtrim(sys_get_temp_dir(), '/\\') . '/triplog_oss_' . $id . '.json';
|
||||
}
|
||||
|
||||
private function readCredCache(): ?array
|
||||
{
|
||||
$f = $this->cachePath();
|
||||
if (!is_file($f)) return null;
|
||||
$raw = @file_get_contents($f);
|
||||
if ($raw === false || $raw === '') return null;
|
||||
$d = json_decode($raw, true);
|
||||
if (!is_array($d) || empty($d['id']) || empty($d['secret']) || (int)($d['expire'] ?? 0) <= time()) {
|
||||
@unlink($f);
|
||||
return null;
|
||||
}
|
||||
return ['id' => (string)$d['id'], 'secret' => (string)$d['secret'],
|
||||
'token' => (string)($d['token'] ?? ''), 'expire' => (int)$d['expire']];
|
||||
}
|
||||
|
||||
private function writeCredCache(array $cred): void
|
||||
{
|
||||
$f = $this->cachePath();
|
||||
@file_put_contents($f, json_encode($cred));
|
||||
@chmod($f, 0600);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────── 工具
|
||||
|
||||
/** 对象键编码:逐段 rawurlencode,保留 '/' */
|
||||
private static function encodeKey(string $key): string
|
||||
{
|
||||
return implode('/', array_map('rawurlencode', explode('/', $key)));
|
||||
}
|
||||
|
||||
private static function normalizePrefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
if ($prefix === '') return '';
|
||||
return rtrim($prefix, '/') . '/';
|
||||
}
|
||||
|
||||
private static function short(string $s): string
|
||||
{
|
||||
$s = trim(preg_replace('/\s+/', ' ', $s) ?? '');
|
||||
return mb_strlen($s) > 200 ? mb_substr($s, 0, 200) . '…' : $s;
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function parseHeaders(array $rawHeaders): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rawHeaders as $line) {
|
||||
$pos = strpos($line, ':');
|
||||
if ($pos === false) continue;
|
||||
$out[strtolower(trim(substr($line, 0, $pos)))] = trim(substr($line, $pos + 1));
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function httpGet(string $url, int $timeout): ?string
|
||||
{
|
||||
$res = self::httpRequest('GET', $url, ['Date' => gmdate('D, d M Y H:i:s \G\M\T')], '', $timeout);
|
||||
return ($res['status'] >= 200 && $res['status'] < 300) ? $res['body'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:int, body:string, headers:string[]}
|
||||
*/
|
||||
private static function httpRequest(string $verb, string $url, array $headers, string $body, int $timeout): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new \RuntimeException('缺少 curl 扩展,无法访问对象存储');
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
$headerLines = [];
|
||||
foreach ($headers as $k => $v) {
|
||||
$headerLines[] = $k . ': ' . $v;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => $verb,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => $headerLines,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
if ($body !== '' || in_array($verb, ['PUT', 'POST'], true)) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$hsize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new \RuntimeException('网络请求失败:' . $err);
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'headers' => explode("\r\n", substr((string)$raw, 0, $hsize)),
|
||||
'body' => substr((string)$raw, $hsize),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* 站点级设置(键值对,存在 app_settings 表)。
|
||||
*
|
||||
* 容错原则:**表还没建好时不能白屏**。
|
||||
* 迁移是显式执行的(tools/migrate.php),万一忘了跑,这里所有读操作
|
||||
* 都退回默认值 —— 表现是「存储退回本地」,而不是整站 500。
|
||||
*/
|
||||
final class Settings
|
||||
{
|
||||
/** 所有已知设置项及其默认值 */
|
||||
public const DEFAULTS = [
|
||||
// 存储后端:local = 本站 uploads/ 目录;oss = 阿里云对象存储
|
||||
'storage_driver' => 'local',
|
||||
|
||||
// —— 阿里云 OSS ——
|
||||
'oss_endpoint' => '', // 如 oss-cn-qingdao.aliyuncs.com
|
||||
'oss_bucket' => '',
|
||||
'oss_prefix' => 'triplog/', // 对象键前缀(相当于目录)
|
||||
'oss_auth' => 'ecs_role', // ecs_role = 用 ECS 实例 RAM 角色换 STS;access_key = 用 AK/SK
|
||||
'oss_role_name' => '', // ECS 角色名,如 SyncCalendarOssRole
|
||||
'oss_ak_id' => '',
|
||||
'oss_ak_secret' => '',
|
||||
'oss_internal' => '1', // 服务器在同地域时走内网 endpoint,免流量费
|
||||
'oss_cname' => '', // 预留:自定义访问域名(当前不使用,代理模式不暴露 OSS)
|
||||
|
||||
// 上传限制
|
||||
'photo_max_mb' => '12', // 单张照片上限(MB)
|
||||
];
|
||||
|
||||
private static ?array $cache = null;
|
||||
|
||||
/** @return array<string,string> */
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::$cache !== null) {
|
||||
return self::$cache;
|
||||
}
|
||||
$out = self::DEFAULTS;
|
||||
try {
|
||||
$stmt = Database::get()->query('SELECT setting_key, setting_value FROM app_settings');
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$k = (string)$row['setting_key'];
|
||||
// 只接受已知键,避免历史遗留垃圾键漏进业务
|
||||
if (array_key_exists($k, self::DEFAULTS)) {
|
||||
$out[$k] = (string)($row['setting_value'] ?? '');
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// app_settings 还不存在(迁移没跑)→ 用默认值,不中断请求
|
||||
}
|
||||
return self::$cache = $out;
|
||||
}
|
||||
|
||||
public static function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
$all = self::all();
|
||||
return $all[$key] ?? $default;
|
||||
}
|
||||
|
||||
public static function flag(string $key): bool
|
||||
{
|
||||
$v = self::get($key);
|
||||
return $v === '1' || $v === 'true' || $v === 'on';
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入若干项。只接受 DEFAULTS 里声明过的键(白名单),避免拼进来的任意键落库。
|
||||
*
|
||||
* @param array<string,string> $pairs
|
||||
*/
|
||||
public static function put(array $pairs, ?PDO $db = null): void
|
||||
{
|
||||
$db = $db ?? Database::get();
|
||||
$stmt = $db->prepare('INSERT INTO app_settings (setting_key, setting_value) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)');
|
||||
$saved = 0;
|
||||
foreach ($pairs as $k => $v) {
|
||||
if (!array_key_exists($k, self::DEFAULTS)) {
|
||||
continue;
|
||||
}
|
||||
$stmt->execute([$k, (string)$v]);
|
||||
$saved++;
|
||||
}
|
||||
self::$cache = null; // 让下一次 all() 重新读
|
||||
if ($saved === 0) {
|
||||
throw new \RuntimeException('没有可保存的设置项');
|
||||
}
|
||||
}
|
||||
|
||||
public static function tableExists(): bool
|
||||
{
|
||||
try {
|
||||
Database::get()->query('SELECT 1 FROM app_settings LIMIT 1');
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否在用 OSS 且配置齐全 */
|
||||
public static function ossReady(): bool
|
||||
{
|
||||
if (self::get('storage_driver') !== 'oss') {
|
||||
return false;
|
||||
}
|
||||
if (self::get('oss_endpoint') === '' || self::get('oss_bucket') === '') {
|
||||
return false;
|
||||
}
|
||||
if (self::get('oss_auth') === 'access_key') {
|
||||
return self::get('oss_ak_id') !== '' && self::get('oss_ak_secret') !== '';
|
||||
}
|
||||
return self::get('oss_role_name') !== '';
|
||||
}
|
||||
|
||||
/** 造一个 OSS 客户端(未启用或配置不全时返回 null) */
|
||||
public static function ossClient(): ?Oss
|
||||
{
|
||||
if (!self::ossReady()) {
|
||||
return null;
|
||||
}
|
||||
return new Oss([
|
||||
'endpoint' => (string)self::get('oss_endpoint'),
|
||||
'bucket' => (string)self::get('oss_bucket'),
|
||||
'prefix' => (string)self::get('oss_prefix'),
|
||||
'auth' => (string)self::get('oss_auth'),
|
||||
'role_name' => (string)self::get('oss_role_name'),
|
||||
'ak_id' => (string)self::get('oss_ak_id'),
|
||||
'ak_secret' => (string)self::get('oss_ak_secret'),
|
||||
'internal' => self::flag('oss_internal'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* 存储门面:把「存哪里」这件事收在一处,业务代码不再关心本地还是 OSS。
|
||||
*
|
||||
* 键的约定:**对象键不含存储后端信息**,两种后端共用同一套命名
|
||||
* 本地: uploads/{key}
|
||||
* OSS : {settings.oss_prefix}{key}
|
||||
* 例:key = "attachments/20260922_ab12cd34.jpg"
|
||||
*
|
||||
* 页面里一律不直接用这里的键拼 URL —— 取文件走 media/show 代理
|
||||
* (桶是私有的,直链既取不到也不该暴露)。
|
||||
*/
|
||||
final class Storage
|
||||
{
|
||||
public const DRIVER_LOCAL = 'local';
|
||||
public const DRIVER_OSS = 'oss';
|
||||
|
||||
/**
|
||||
* 当前生效的驱动。OSS 配置不全时**静默回退本地**,
|
||||
* 这样即使管理员填错了配置,上传也不会整个不可用。
|
||||
*/
|
||||
public static function driver(): string
|
||||
{
|
||||
if (Settings::get('storage_driver') === self::DRIVER_OSS && Settings::ossReady()) {
|
||||
return self::DRIVER_OSS;
|
||||
}
|
||||
return self::DRIVER_LOCAL;
|
||||
}
|
||||
|
||||
/** 是否处于「选了 OSS 但配置不全,已回退本地」的状态(用于在界面上给出提示) */
|
||||
public static function ossMisconfigured(): bool
|
||||
{
|
||||
return Settings::get('storage_driver') === self::DRIVER_OSS && !Settings::ossReady();
|
||||
}
|
||||
|
||||
public static function label(?string $driver = null): string
|
||||
{
|
||||
return ($driver ?? self::driver()) === self::DRIVER_OSS ? '阿里云 OSS' : '本站服务器';
|
||||
}
|
||||
|
||||
/** 生成一个不会撞的新对象键 */
|
||||
public static function keyFor(string $ext, string $dir = 'attachments'): string
|
||||
{
|
||||
$ext = ltrim(strtolower($ext), '.');
|
||||
$ext = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
|
||||
return $dir . '/' . date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一份内容。
|
||||
* @return array{storage:string, key:string, size:int}
|
||||
*/
|
||||
public static function put(string $key, string $body, string $mime): array
|
||||
{
|
||||
$driver = self::driver();
|
||||
|
||||
if ($driver === self::DRIVER_OSS) {
|
||||
$oss = Settings::ossClient();
|
||||
if ($oss === null) {
|
||||
throw new \RuntimeException('OSS 未配置完整');
|
||||
}
|
||||
$oss->put($key, $body, $mime);
|
||||
return ['storage' => self::DRIVER_OSS, 'key' => $key, 'size' => strlen($body)];
|
||||
}
|
||||
|
||||
$path = self::localPath($key);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
throw new \RuntimeException('无法创建上传目录:' . $dir);
|
||||
}
|
||||
if (@file_put_contents($path, $body) === false) {
|
||||
throw new \RuntimeException('写入文件失败:' . $key);
|
||||
}
|
||||
return ['storage' => self::DRIVER_LOCAL, 'key' => $key, 'size' => strlen($body)];
|
||||
}
|
||||
|
||||
/** 读取内容;找不到返回 null */
|
||||
public static function get(string $storage, string $key, string $fileUrl = ''): ?string
|
||||
{
|
||||
if ($storage === self::DRIVER_OSS) {
|
||||
try {
|
||||
$oss = Settings::ossClient();
|
||||
return $oss === null ? null : $oss->get($key);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$path = self::resolveLocal($key, $fileUrl);
|
||||
if ($path === null || !is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$data = @file_get_contents($path);
|
||||
return $data === false ? null : $data;
|
||||
}
|
||||
|
||||
public static function exists(string $storage, string $key, string $fileUrl = ''): bool
|
||||
{
|
||||
if ($storage === self::DRIVER_OSS) {
|
||||
$oss = Settings::ossClient();
|
||||
if ($oss === null) return false;
|
||||
try {
|
||||
return $oss->head($key) !== null;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$path = self::resolveLocal($key, $fileUrl);
|
||||
return $path !== null && is_file($path);
|
||||
}
|
||||
|
||||
/** 删除;返回是否确实删掉了(不存在也算成功,保持幂等) */
|
||||
public static function delete(string $storage, string $key, string $fileUrl = ''): bool
|
||||
{
|
||||
if ($storage === self::DRIVER_OSS) {
|
||||
try {
|
||||
$oss = Settings::ossClient();
|
||||
return $oss === null ? false : $oss->delete($key);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$path = self::resolveLocal($key, $fileUrl);
|
||||
if ($path === null) {
|
||||
return true;
|
||||
}
|
||||
return !is_file($path) || @unlink($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地文件的绝对路径。只接受 uploads/ 下的相对键,挡住 `../` 之类的路径穿越。
|
||||
* $fileUrl 是历史遗留的 `/uploads/attachments/xxx.jpg` 形式,作为兼容兜底。
|
||||
*/
|
||||
public static function localPath(string $key): string
|
||||
{
|
||||
$key = ltrim(str_replace('\\', '/', $key), '/');
|
||||
if (strpos($key, '..') !== false) {
|
||||
throw new \RuntimeException('非法的对象键:' . $key);
|
||||
}
|
||||
return self::uploadsDir() . '/' . $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本地文件的绝对路径(找不到/键非法返回 null)。
|
||||
* public 是给「展示层算体积」这类只读场景用的,仍会挡住路径穿越。
|
||||
*/
|
||||
public static function resolveLocal(string $key, string $fileUrl = ''): ?string
|
||||
{
|
||||
$key = trim($key) !== '' ? $key : '';
|
||||
if ($key !== '') {
|
||||
try {
|
||||
return self::localPath($key);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 老数据只有 file_url(/uploads/...)
|
||||
$url = trim($fileUrl);
|
||||
if ($url === '' || strpos($url, '/uploads/') !== 0 || strpos($url, '..') !== false) {
|
||||
return null;
|
||||
}
|
||||
return dirname(__DIR__) . $url;
|
||||
}
|
||||
|
||||
public static function uploadsDir(): string
|
||||
{
|
||||
return dirname(__DIR__) . '/uploads';
|
||||
}
|
||||
}
|
||||
+64
-1
@@ -65,19 +65,82 @@ function csrf_token(): string
|
||||
return $_SESSION['_csrf'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 CSRF。两个通道都收:
|
||||
* 表单 → 隐藏字段 _csrf
|
||||
* XHR → 请求头 X-CSRF-Token(自定义头跨站加不上,同样安全,且不必把 token 塞进 body)
|
||||
*/
|
||||
function csrf_check(): void
|
||||
{
|
||||
$token = $_POST['_csrf'] ?? '';
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
|
||||
if ($token === '') {
|
||||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
}
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', (string)$token)) {
|
||||
http_response_code(419);
|
||||
exit('CSRF token 校验失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件的查看地址 —— 走 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';
|
||||
}
|
||||
Reference in New Issue
Block a user