附件:支持视频 / 文档上传,可预览的浏览器直接看,预览不了的给下载
之前节点附件只能传图片和 PDF(accept 过滤 + 20MB 上限),视频传不上来。 本批放开:MP4 / MOV / PDF / Word / Excel / ZIP 都能作为附件上传, 仍走同一份 Uploads::handleFiles(MIME 白名单三层识别不变),存进已配置的 OSS。 大小分级:照片仍走 photo_max_mb(管理员可调),视频与文档放宽到 40MB (Uploads::MAX_FILE_BYTES,给线上 post_max_size=50M 留余量),先 detect 再按 is_image 选上限。前端 form.php / attachments.php 同步拦截并按类型细分暂存区图标。 预览走灯箱四类型视图:图片(img)/ 视频(<video>,配合 media/show 新增的 HTTP Range 单区间 206/416,进度条可拖、Safari 也能播)/ PDF(iframe 内嵌 阅读器)/ 其它(Word / ZIP 等浏览器预览不了 → 灯箱里给下载按钮)。 dl=1 下载不接 Range;多区间回退 200 全量。 已验证:本地全链 11 步全绿(渲染检查 308 项、表单交互 73 项); 远端 e2e 0 失败(新增 mp4 上传落库 video/mp4、Range 206/416/后缀区间/ 多区间回退/dl 不接 Range 专项断言);部署后双 PHP 渲染逐字节一致。
This commit is contained in:
@@ -107,7 +107,13 @@ class MediaController
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出二进制内容;$download=true 时强制下载而不是内联预览 */
|
||||
/**
|
||||
* 输出二进制内容;$download=true 时强制下载而不是内联预览。
|
||||
*
|
||||
* 支持 HTTP Range(单区间):浏览器里的 <video> 要靠 206 才能拖动进度条,
|
||||
* Safari 更是不给 Range 就直接不播。文件最大 40MB 且已整体读进 $body,
|
||||
* 这里只做内存内切片,不再碰存储后端(OSS 的 Range GET 收益不大、复杂度不低)。
|
||||
*/
|
||||
private function stream(string $body, string $mime, string $fileName, bool $download): void
|
||||
{
|
||||
// 别让 PHP 的报错/警告混进图片正文(那会让浏览器把图片判成损坏)
|
||||
@@ -118,14 +124,48 @@ class MediaController
|
||||
$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('Accept-Ranges: bytes');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
// 私有内容:允许浏览器缓存,但禁止中间代理缓存
|
||||
header('Cache-Control: private, max-age=86400');
|
||||
header('Pragma: private');
|
||||
|
||||
$size = strlen($body);
|
||||
|
||||
// 下载不接 Range(客户端要的是完整文件);多区间(带逗号)不支持,回退 200 全量
|
||||
$rangeHeader = $download ? '' : (string)($_SERVER['HTTP_RANGE'] ?? '');
|
||||
if ($rangeHeader !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', trim($rangeHeader), $m)
|
||||
&& strpos($rangeHeader, ',') === false) {
|
||||
if ($m[1] === '' && $m[2] === '') {
|
||||
// bytes=- 无意义,回退 200
|
||||
} elseif ($size > 0) {
|
||||
if ($m[1] === '') {
|
||||
// bytes=-N → 末尾 N 字节
|
||||
$len = min((int)$m[2], $size);
|
||||
$start = $size - $len;
|
||||
$end = $size - 1;
|
||||
} else {
|
||||
$start = (int)$m[1];
|
||||
$end = $m[2] === '' ? $size - 1 : min((int)$m[2], $size - 1);
|
||||
}
|
||||
if ($start > $end || $start >= $size) {
|
||||
http_response_code(416);
|
||||
header("Content-Range: bytes */$size");
|
||||
exit;
|
||||
}
|
||||
http_response_code(206);
|
||||
header("Content-Range: bytes $start-$end/$size");
|
||||
header('Content-Length: ' . ($end - $start + 1));
|
||||
header('Content-Disposition: inline; filename="' . $ascii . '"; filename*=UTF-8\'\''
|
||||
. rawurlencode($fileName));
|
||||
echo substr($body, $start, $end - $start + 1);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Length: ' . $size);
|
||||
header('Content-Disposition: ' . ($download ? 'attachment' : 'inline')
|
||||
. '; filename="' . $ascii . '"; filename*=UTF-8\'\'' . rawurlencode($fileName));
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
+20
-8
@@ -20,9 +20,17 @@ final class Uploads
|
||||
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
||||
public const MAX_FILES = 30;
|
||||
|
||||
/** 单文件硬上限 */
|
||||
/** 单文件硬上限(兜底值;实际限制见 handleFiles 里的分级逻辑) */
|
||||
public const MAX_BYTES = 20971520; // 20MB
|
||||
|
||||
/**
|
||||
* 非图片文件(视频 / PDF / Word 等)的单文件上限。
|
||||
* 视频普遍比照片大,20MB 根本不够用;但这再大也大不过服务器的
|
||||
* post_max_size(超了整个请求会被 PHP 静默丢弃),所以取 40MB,
|
||||
* 给 50M 的 post_max_size 留出表单字段的余量。
|
||||
*/
|
||||
public const MAX_FILE_BYTES = 41943040; // 40MB
|
||||
|
||||
/**
|
||||
* 把 php.ini 里的 "8M" / "50M" 这类写法换算成字节数。
|
||||
*
|
||||
@@ -89,20 +97,24 @@ final class Uploads
|
||||
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
||||
continue;
|
||||
}
|
||||
$limit = $photoMax > 0 ? max($photoMax, self::MAX_BYTES) : self::MAX_BYTES;
|
||||
if ($file['size'] > $limit) {
|
||||
$errors[] = $file['name'] . ':超过大小上限';
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 先识别类型再限大小:图片走管理员设的 photo_max_mb(12MB),
|
||||
视频 / PDF / Word 等非图片走更宽的 MAX_FILE_BYTES(40MB)。
|
||||
detectFile 只读文件头 32 字节,先跑也不费。 */
|
||||
$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';
|
||||
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';
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user