Files
FeedLite/upload.php
T
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

208 lines
8.4 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):
*
* ① oss(阿里云 OSS 直传,原行为)
* POST upload.php kind=&ext=&size=&mime=&csrf=
* → 返回 host / key / policy / signature…,浏览器把文件直接 POST 到 OSS,
* 文件不经过本服务器。凭证来自 ECS 实例 RAM 角色的临时凭证(或静态 AK)。
*
* ② local(存本机磁盘)
* 第一步同上,只是返回的 host 指向本文件的 ?action=put;
* 第二步浏览器把文件 multipart POST 到 upload.php?action=put,
* 服务端校验后落到 uploads/YYYY/MM/ 下。
*
* 两种方式共用同一套校验:扩展名 / 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);
}
/* ==================================================================
* 本机存储:接收文件本体
* ================================================================== */
if ($putAt) {
if ($mode !== 'local') { 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_attach_is_local($key) || !fb_upload_key_ok($key)) {
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);
}
$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);
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);
}
/* ---------- 本机存储:返回一个指向本文件的上传地址 ---------- */
if ($mode === 'local') {
$key = fb_local_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,
]);
}
/* ---------- OSS 直传 ---------- */
$key = fb_oss_make_key($ext);
$cred = fb_oss_upload_credential($key, $maxBytes, $kind . '/');
fb_rate_hit('upload');
// extra_fields 是除固定项外需要浏览器一并提交的表单域:使用实例 RAM 角色的
// 临时凭证时,就是 x-oss-security-token(OSS 靠它识别这份临时身份)。
$fields = [
'key' => $cred['key'],
'policy' => $cred['policy'],
'OSSAccessKeyId' => $cred['access_key_id'],
'signature' => $cred['signature'],
'success_action_status' => '200',
'Content-Type' => $mime,
];
if (!empty($cred['extra_fields']) && is_array($cred['extra_fields'])) {
foreach ($cred['extra_fields'] as $k => $v) { $fields[$k] = $v; }
}
fb_up_json([
'ok' => true,
'credential' => $cred,
'fields' => $fields,
'mime' => $mime,
'kind' => $kind,
]);