Files
FeedLite/upload.php
T
laoyang 5006d54be3 附件读取统一走 media.php 代理,真实地址不再暴露
无论附件存在本机 uploads/ 还是 OSS 私有桶,前台给浏览器的地址现在完全一致:
media.php?k=<对象键>。界面上不再出现 uploads/ 真实路径,也不出现任何 bucket /
endpoint / 签名痕迹。

- lib/attach.php:新增 fb_attach_proxy_url(),fb_attach_url() 与
  fb_attach_thumb_url() 的本机分支一并改走代理(地址不落库、渲染现算,
  对历史附件立即生效,无需迁移)。
- uploads/ 整目录禁止 Web 直读:附件只能经 media.php 出,真实物理路径
  即便被猜到也取不到,同时绕不开 media.php 的可见性鉴权。
- 规则版本化自动升级:.htaccess 内埋 `# fb-local-guard: vN`,
  旧版规则在任意请求时由 PHP(目录属主)整份重写。之所以不能靠部署覆盖:
  部署账号对 uploads/ 无写权限,只有程序自己能改。
- 随包 uploads/.htaccess 改为程序产出的逐字节副本,并加断言防漂移
  (否则线上会被自动升级覆盖,随包那份形同虚设)。
- 顺带修复真实源码泄漏:config.php.bak_<时间戳> 这类「非 .php 结尾、
  但含源码」的备份文件此前可被公开下载。加固根 .htaccess:
  去掉正则结尾锚点、补 .php[.\-_] 兜底、点名拒绝 config.php / oss.sts.php。
- media.php / upload.php / admin.php / README.md:文档与响应头同步。

验证:php -l 19 文件 0 失败;主回归 25/25、附件专项 22/22、安装向导 60/60、
统一代理渲染 7/7、反向验证 V1-V4 全被抓住;线上冒烟 30/30、
线上统一代理真机 13/13(OSS 私有桶 + 内网 endpoint 下上传→代理读回→
Range 206→伪造键 403)。
2026-09-28 23:33:45 +08:00

216 lines
9.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 附件上传接口(两种存储方式都走「两步」,前端代码完全一致)
* ------------------------------------------------------------------
* 存储方式由后台设置决定(attachment.storage):off / local / oss。
* 无论 local 还是 oss,浏览器都【不直连存储】,一律两步走本站:
*
* 第一步:POST upload.php kind=&ext=&size=&mime=&csrf=
* → 服务器生成对象键,返回 host=upload.php?action=put 与随传字段。
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,服务器校验后落地:
* · local → 存到 uploads/YYYY/MM/ 下;
* · oss → 服务器用实例 RAM 角色的临时凭证,代理 PUT 到【内网 Endpoint】的私有桶。
*
* ★ 为什么 OSS 也要经服务器中转:桶是私有的、且只用内网 Endpoint(外网不可达),
* 浏览器既拿不到可直连的 URL、也连不上内网地址,所以「浏览器直传」在此架构下不成立。
* 读取同理,走 media.php 代理(见该文件)。
*
* ★ 前台地址与存储方式无关:local 与 oss 返回的 url 都是 media.php?k=...,
* 页面/前端 JS 看不到 uploads/ 真实路径,也看不到任何 OSS 痕迹。
*
* 两种方式共用同一套校验:扩展名 / MIME 白名单、单文件大小、每 IP 频率、全站总量上限。
*/
require __DIR__ . '/lib/bootstrap.php';
/** 统一 JSON 输出 */
function fb_up_json($data, $code = 200) {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (strtoupper(isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET') !== 'POST') {
fb_up_json(['ok' => false, 'error' => 'method not allowed'], 405);
}
$mode = fb_store_mode();
$putAt = (isset($_GET['action']) && (string)$_GET['action'] === 'put');
if ($mode === 'off') {
fb_up_json(['ok' => false, 'error' => '附件上传未启用。'], 403);
}
if ($mode === 'oss' && !fb_oss_enabled()) {
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整,请到后台「站点设置」补齐桶信息。'], 403);
}
if ($mode === 'local' && !fb_local_writable()) {
$ld = fb_local_diag();
fb_up_json(['ok' => false, 'error' => '本机附件目录不可写(uploads/)。' . ($ld['tip'] !== '' ? ' ' . $ld['tip'] : '')], 403);
}
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
fb_up_json(['ok' => false, 'error' => '会话已过期,请刷新页面后重试。'], 400);
}
/* ==================================================================
* 接收文件本体并落地(local → 本机磁盘;oss → 服务器代理 PUT 到内网 OSS)
* ------------------------------------------------------------------
* 私有桶 + 内网 Endpoint 下浏览器无法直传,所以 OSS 模式也走这里:
* 浏览器把文件 POST 给本站,本站再用服务器身份 PUT 进 OSS。
* ================================================================== */
if ($putAt) {
if ($mode !== 'local' && $mode !== 'oss') {
fb_up_json(['ok' => false, 'error' => '当前存储方式不支持上传。'], 400);
}
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
$key = trim(isset($_POST['key']) ? (string)$_POST['key'] : '');
if ($kind !== 'image' && $kind !== 'video') { fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400); }
if (!fb_upload_kind_on($kind)) { fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403); }
if (!fb_upload_key_ok($key)) {
fb_up_json(['ok' => false, 'error' => '附件地址不合法,请重新上传。'], 400);
}
// key 的存储类型必须与当前 mode 一致(防止 local 会话塞 oss key 或反之)
$keyIsLocal = fb_attach_is_local($key);
if ($mode === 'local' && !$keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
if ($mode === 'oss' && $keyIsLocal) { fb_up_json(['ok' => false, 'error' => '附件地址与当前存储方式不符。'], 400); }
$ext = fb_upload_key_ext($key);
if (!fb_upload_ext_ok($kind, $ext)) { fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400); }
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
fb_up_json(['ok' => false, 'error' => '没有收到文件。'], 400);
}
$f = $_FILES['file'];
if (!isset($f['error']) || (int)$f['error'] !== UPLOAD_ERR_OK) {
fb_up_json(['ok' => false, 'error' => '文件上传失败(错误码 ' . (int)(isset($f['error']) ? $f['error'] : -1) . ')。'], 400);
}
if (!is_uploaded_file((string)$f['tmp_name'])) {
fb_up_json(['ok' => false, 'error' => '文件上传失败。'], 400);
}
$size = (int)@filesize($f['tmp_name']);
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($size <= 0) { fb_up_json(['ok' => false, 'error' => '文件为空。'], 400); }
if ($maxB > 0 && $size > $maxB) {
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxB) . ')。'], 400);
}
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); }
if (!fb_upload_mime_ok($kind, $mime)) { fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400); }
// 二次确认总量上限(防止并发绕过)
$pdo = fb_db();
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传。'], 507);
}
if ($mode === 'local') {
$path = fb_local_path_of($key);
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
if (!is_dir($dir) || !@move_uploaded_file((string)$f['tmp_name'], $path)) {
fb_up_json(['ok' => false, 'error' => '保存文件失败,请稍后重试。'], 500);
}
@chmod($path, 0644);
} else {
// OSS:服务器代理 PUT 到内网桶(流式,不整文件进内存)
if (!fb_oss_enabled()) {
fb_up_json(['ok' => false, 'error' => '对象存储未配置完整。'], 403);
}
list($ok, $ossErr, $ossStatus) = fb_oss_put_file($key, (string)$f['tmp_name'], $mime);
if (!$ok) {
fb_up_json(['ok' => false, 'error' => '写入对象存储失败:' . $ossErr], 502);
}
}
// 登记「待入库」:让上传后、提交表单前的缩略图预览能通过 media.php 鉴权
fb_attach_pending_mark($key, $kind, $mime, $size);
fb_up_json([
'ok' => true,
'key' => $key,
'url' => fb_attach_url($key),
'size' => $size,
'mime' => $mime,
]);
}
/* ==================================================================
* 领取上传凭证
* ================================================================== */
$kind = strtolower(trim(isset($_POST['kind']) ? (string)$_POST['kind'] : ''));
if ($kind !== 'image' && $kind !== 'video') {
fb_up_json(['ok' => false, 'error' => '不支持的附件类型。'], 400);
}
if (!fb_upload_kind_on($kind)) {
fb_up_json(['ok' => false, 'error' => '该类型附件未开放上传。'], 403);
}
$ext = fb_upload_norm_ext(isset($_POST['ext']) ? (string)$_POST['ext'] : '');
$size = (int)(isset($_POST['size']) ? $_POST['size'] : 0);
$mime = strtolower(trim(isset($_POST['mime']) ? (string)$_POST['mime'] : ''));
if ($mime === '') { $mime = fb_upload_mime_of_ext($ext); }
$maxBytes = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($ext === '' || !fb_upload_ext_ok($kind, $ext)) {
fb_up_json(['ok' => false, 'error' => '不支持的文件格式。'], 400);
}
if (!fb_upload_mime_ok($kind, $mime)) {
fb_up_json(['ok' => false, 'error' => '不支持的文件类型。'], 400);
}
if ($size <= 0) {
fb_up_json(['ok' => false, 'error' => '文件为空。'], 400);
}
if ($maxBytes > 0 && $size > $maxBytes) {
fb_up_json(['ok' => false, 'error' => '文件超过大小限制(' . fb_size_h($maxBytes) . ')。'], 400);
}
$limit = (int)fb_upload_cfg('upload_per_hour', 60);
if (!fb_rate_check('upload', $limit)) {
fb_up_json(['ok' => false, 'error' => '上传过于频繁,请稍后再试。'], 429);
}
// 全站总量上限:达到后不再发放凭证
$pdo = fb_db();
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
if ($cap > 0 && (fb_attach_total_bytes($pdo) + $size) > $cap) {
fb_up_json(['ok' => false, 'error' => '附件存储空间已满,暂时无法上传,请稍后再试。'], 507);
}
/* ---------- 领取上传凭证:local 与 oss 都指向本站 action=put ----------
* 私有桶 + 内网 Endpoint 下浏览器无法直传 OSS,所以两种模式统一为:
* 第一步(这里):服务器生成对象键、返回本站上传地址与随传字段;
* 第二步:浏览器把文件 multipart POST 到 upload.php?action=put,
* 由服务器落地(本机磁盘 / 或代理 PUT 到内网 OSS)。
* 前端 app.js 因此对两种模式走完全相同的代码路径。
*/
if ($mode === 'local') {
$key = fb_local_make_key($ext);
} else {
$key = fb_oss_make_key($ext);
}
fb_rate_hit('upload');
fb_up_json([
'ok' => true,
'credential' => [
'host' => 'upload.php?action=put',
'key' => $key,
'url' => fb_attach_url($key),
],
'fields' => [
'csrf' => fb_csrf_token(),
'key' => $key,
'kind' => $kind,
'mime' => $mime,
],
'mime' => $mime,
'kind' => $kind,
]);