2026-09-23 07:53:20 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把「一个请求里带上来的文件」写成附件。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 抽成独立类是因为有两个入口都要用它:
|
|
|
|
|
|
* · AttachmentController::upload —— 节点照片页的独立上传
|
|
|
|
|
|
* · NodeController::store/update —— 节点表单里跟节点一起提交的附件
|
|
|
|
|
|
* 两边各写一份的话,MIME 白名单、大小上限、孤儿文件回滚这些迟早会走偏。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 两类入参(沿用原设计,别再合并):
|
|
|
|
|
|
* files[] —— 普通 multipart 文件(原图、PDF……)省流量,不预压
|
|
|
|
|
|
* cropped[] —— 前端裁过/压过的图片,JSON 字符串 [{"name":..,"data":"data:image/..;base64,.."}]
|
|
|
|
|
|
*/
|
|
|
|
|
|
final class Uploads
|
|
|
|
|
|
{
|
|
|
|
|
|
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
|
|
|
|
|
public const MAX_FILES = 30;
|
|
|
|
|
|
|
2026-09-23 16:22:01 +08:00
|
|
|
|
/** 单文件硬上限(兜底值;实际限制见 handleFiles 里的分级逻辑) */
|
2026-09-23 07:53:20 +08:00
|
|
|
|
public const MAX_BYTES = 20971520; // 20MB
|
|
|
|
|
|
|
2026-09-23 16:22:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 非图片文件(视频 / PDF / Word 等)的单文件上限。
|
|
|
|
|
|
* 视频普遍比照片大,20MB 根本不够用;但这再大也大不过服务器的
|
|
|
|
|
|
* post_max_size(超了整个请求会被 PHP 静默丢弃),所以取 40MB,
|
|
|
|
|
|
* 给 50M 的 post_max_size 留出表单字段的余量。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const MAX_FILE_BYTES = 41943040; // 40MB
|
|
|
|
|
|
|
2026-09-23 07:53:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把 php.ini 里的 "8M" / "50M" 这类写法换算成字节数。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 直接转发给全局的 ini_bytes()(helpers.php 里那个,模板也在用)——
|
|
|
|
|
|
* 项目里只能有一份实现,否则两处迟早对 '-1'(不限)这类边界值理解不一致。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function iniBytes(string $value): int
|
|
|
|
|
|
{
|
|
|
|
|
|
return \ini_bytes($value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 「整个请求体超过 post_max_size」时,PHP 会**静默丢掉 $_POST 和 $_FILES**,
|
|
|
|
|
|
* 表现为「点了保存但什么都没发生 / CSRF 校验失败」——最难排查的一类问题。
|
|
|
|
|
|
* 这里按 CONTENT_LENGTH 提前认出来,好给用户一句能懂的话。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function requestTooLarge(): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
|
|
|
|
|
if ($len <= 0) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
$max = self::iniBytes((string)ini_get('post_max_size'));
|
|
|
|
|
|
return $max > 0 && $len > $max;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 站点设置的「单张照片上限」(字节) */
|
|
|
|
|
|
public static function maxPhotoBytes(): int
|
|
|
|
|
|
{
|
|
|
|
|
|
return (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理 $_FILES 里的某个多选字段(形状来自 name="xxx[]")。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $countStart 已经计入上限的文件数(files 与 cropped 共用同一个上限)
|
|
|
|
|
|
* @return array{uploaded:int, errors:string[], count:int}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function handleFiles(
|
|
|
|
|
|
array $filesField,
|
|
|
|
|
|
string $targetType,
|
|
|
|
|
|
int $targetId,
|
|
|
|
|
|
int $uploaderId,
|
|
|
|
|
|
int $countStart = 0
|
|
|
|
|
|
): array {
|
|
|
|
|
|
$uploaded = 0;
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
$count = $countStart;
|
|
|
|
|
|
|
|
|
|
|
|
if (empty($filesField) || !is_array($filesField['name'] ?? null)) {
|
|
|
|
|
|
return ['uploaded' => 0, 'errors' => [], 'count' => $count];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$photoMax = self::maxPhotoBytes();
|
|
|
|
|
|
|
|
|
|
|
|
foreach (self::restructure($filesField) 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 16:22:01 +08:00
|
|
|
|
/* 先识别类型再限大小:图片走管理员设的 photo_max_mb(12MB),
|
|
|
|
|
|
视频 / PDF / Word 等非图片走更宽的 MAX_FILE_BYTES(40MB)。
|
|
|
|
|
|
detectFile 只读文件头 32 字节,先跑也不费。 */
|
2026-09-23 07:53:20 +08:00
|
|
|
|
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
|
|
|
|
|
$mime = $info['mime'];
|
|
|
|
|
|
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
|
|
|
|
|
$errors[] = $file['name'] . ':不支持的文件类型';
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-09-23 16:22:01 +08:00
|
|
|
|
if ($info['is_image']) {
|
|
|
|
|
|
$limit = $photoMax > 0 ? $photoMax : self::MAX_BYTES;
|
|
|
|
|
|
if ($file['size'] > $limit) {
|
|
|
|
|
|
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
} elseif ($file['size'] > self::MAX_FILE_BYTES) {
|
|
|
|
|
|
$errors[] = $file['name'] . ':文件超过 ' . (int)(self::MAX_FILE_BYTES / 1048576) . 'MB';
|
2026-09-23 07:53:20 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$raw = @file_get_contents($file['tmp_name']);
|
|
|
|
|
|
if ($raw === false) {
|
|
|
|
|
|
$errors[] = $file['name'] . ':读取临时文件失败';
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$err = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime, $file['name'], $info);
|
|
|
|
|
|
if ($err === null) {
|
|
|
|
|
|
$uploaded++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$errors[] = $file['name'] . ':' . $err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理前端裁剪/压缩后的图片(base64 data URL)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{uploaded:int, errors:string[], count:int}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function handleCropped(
|
|
|
|
|
|
string $json,
|
|
|
|
|
|
string $targetType,
|
|
|
|
|
|
int $targetId,
|
|
|
|
|
|
int $uploaderId,
|
|
|
|
|
|
int $countStart = 0
|
|
|
|
|
|
): array {
|
|
|
|
|
|
$uploaded = 0;
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
$count = $countStart;
|
|
|
|
|
|
|
|
|
|
|
|
if (trim($json) === '') {
|
|
|
|
|
|
return ['uploaded' => 0, 'errors' => [], 'count' => $count];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$items = json_decode($json, true);
|
|
|
|
|
|
if (!is_array($items)) {
|
|
|
|
|
|
return ['uploaded' => 0, 'errors' => ['裁剪数据解析失败'], 'count' => $count];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) > self::MAX_BYTES) {
|
|
|
|
|
|
$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 = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime,
|
|
|
|
|
|
self::imageName($name, $mime), $info);
|
|
|
|
|
|
if ($err === null) {
|
|
|
|
|
|
$uploaded++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$errors[] = $name . ':' . $err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
|
|
|
|
|
|
public static function storeOne(
|
|
|
|
|
|
string $targetType,
|
|
|
|
|
|
int $targetId,
|
|
|
|
|
|
int $uploaderId,
|
|
|
|
|
|
string $raw,
|
|
|
|
|
|
string $mime,
|
|
|
|
|
|
string $fileName,
|
|
|
|
|
|
array $info
|
|
|
|
|
|
): ?string {
|
|
|
|
|
|
$ext = Mime::extFor($mime);
|
|
|
|
|
|
if ($ext === null) {
|
|
|
|
|
|
return '无法确定扩展名';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
$key = Storage::keyFor($ext, 'attachments');
|
|
|
|
|
|
$stored = Storage::put($key, $raw, $mime);
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
return $e->getMessage();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
Attachments::create(
|
|
|
|
|
|
$targetType,
|
|
|
|
|
|
$targetId,
|
|
|
|
|
|
$uploaderId,
|
|
|
|
|
|
$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 这类名字,避免扩展名与真实格式不符 */
|
|
|
|
|
|
public static function imageName(string $name, string $mime): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo';
|
|
|
|
|
|
return $base . '.' . (Mime::extFor($mime) ?? 'jpg');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 把 $_FILES 的列式数组转成一列「一个文件一项」 */
|
|
|
|
|
|
public static function restructure(array $files): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$result = [];
|
|
|
|
|
|
$count = count($files['name']);
|
|
|
|
|
|
for ($i = 0; $i < $count; $i++) {
|
|
|
|
|
|
if (empty($files['name'][$i])) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$result[] = [
|
|
|
|
|
|
'name' => $files['name'][$i],
|
|
|
|
|
|
'type' => $files['type'][$i],
|
|
|
|
|
|
'tmp_name' => $files['tmp_name'][$i],
|
|
|
|
|
|
'error' => $files['error'][$i],
|
|
|
|
|
|
'size' => $files['size'][$i],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
return $result;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|