Files
FeedLite/lib/layout.php
T
laoyang 1401ad899b OSS 附件改为私有桶+内网Endpoint+服务器代理架构
背景:桶为私有、且只用内网 Endpoint(外网不可达),浏览器既无法直传、
也无法直连。原「浏览器直传 + 签名URL直连」两条路都失效,改为全站代理。

lib/oss.php:
- endpoint 自动转内网(*-internal),use_internal_endpoint 开关控制,内网走 http
- 移除浏览器直传凭证(PostObject),改为服务器端 PUT(流式 fb_oss_put_file)
- 新增 fb_oss_stream_get 流式读取(回调式,支持 Range,不整文件进内存)
- 签名支持 x-oss-process 子资源 + Authorization 头签名(Content-Type 入签)
- 新增 fb_oss_probe 连通性自检、fb_oss_error_text 错误归因(含跨地域提示)

media.php(新增): 附件代理输出,鉴权边界=对象键须在本站附件表(或本人待入库
  登记)且帖子对访客可见,防止本站沦为能读整个共用桶的开放代理;透传 Range。

upload.php: local/oss 统一走两步(领键→action=put),OSS 模式由服务器代理 PUT。
lib/db.php: 新增 pending_attachments 表 + idx_att_key 索引(迁移幂等自动建)。
lib/attach.php: 待入库登记(mark/clear/mine/gc),解决上传后入库前的预览鉴权。
cron.php: 清理过期待入库登记 + 桶连通性自检输出。
admin.php: 设置页加「使用内网 Endpoint」开关、存储体检显示实际连接地址与探测按钮。
README/config: 全面更新为代理架构(无需CORS、无需自定义域名、同地域要求)。

验证:本地 e2e 25/25 + 附件专项 13/13(含防开放代理/Range/内网改写);
线上真机上传→内网PUT→media.php代理读回→Range 全通过。
2026-09-28 22:12:28 +08:00

135 lines
5.4 KiB
PHP

<?php
/**
* 页面外壳(头部 / 底部)
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/**
* 输出页面头部
* @param string $title 页面标题
* @param array $opt ['robots' => 'noindex', 'desc' => '...']
*/
function fb_layout_head($title, array $opt = []) {
$site = (string)fb_cfg('site_name');
$robots = isset($opt['robots']) ? (string)$opt['robots'] : 'index,follow';
$full = $title === '' ? $site : ($title . ' · ' . $site);
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0057B8">
<meta name="robots" content="<?php echo e($robots); ?>">
<title><?php echo e($full); ?></title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<link rel="manifest" href="assets/site.webmanifest">
<link rel="stylesheet" href="assets/style.css?v=6">
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand-mini" href="./" title="<?php echo e($site); ?>">
<span class="logo-mark" role="img" aria-label="轻反馈图标"></span>
<span class="brand-text"><?php echo e($site); ?></span>
</a>
<div class="spacer"></div>
<a class="navlink hide-sm" href="./">全部问题</a>
<?php if (fb_admin_logged_in()): ?><a class="navlink" href="admin.php">管理后台</a><?php endif; ?>
<a class="btn-mini" href="new.php">我要反馈</a>
</div>
</header>
<main class="wrap">
<?php
}
/** 输出页面底部 */
function fb_layout_foot() {
?><footer class="foot">
<div class="wrap">
<p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
<p class="muted"><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 FeedLite 驱动</p>
</div>
</footer>
<script src="assets/app.js?v=5"></script>
</body>
</html><?php
}
/** 渲染提示条 */
function fb_flash_box($msg, $type = 'error') {
if ($msg === '' || $msg === null) { return; }
echo '<div class="flash ' . e($type) . '">' . e($msg) . '</div>';
}
/**
* 渲染附件上传控件(图片 / 视频;上传经服务器中转落地,local/OSS 前端同一路径)
* 需置于 <form> 内部:会输出一个 name="attachments" 的隐藏字段,
* 由 assets/app.js 在文件上传成功后回填 JSON 描述。
*
* @param array $existing 校验失败时已上传的附件描述(避免用户重复上传)
*/
function fb_uploaders(array $existing = []) {
$kinds = [];
if (fb_upload_kind_on('image')) { $kinds[] = 'image'; }
if (fb_upload_kind_on('video')) { $kinds[] = 'video'; }
if (!$kinds) { return; }
$init = [];
foreach ($existing as $it) {
$kind = isset($it['kind']) ? (string)$it['kind'] : 'image';
$key = isset($it['key']) ? (string)$it['key'] : '';
if ($key === '') { continue; }
$init[] = [
'kind' => $kind,
'key' => $key,
'size' => isset($it['size']) ? (int)$it['size'] : 0,
'name' => isset($it['name']) ? (string)$it['name'] : '',
'mime' => isset($it['mime']) ? (string)$it['mime'] : '',
'w' => isset($it['width']) ? (int)$it['width'] : 0,
'h' => isset($it['height']) ? (int)$it['height'] : 0,
'url' => fb_attach_url($key),
'thumb' => $kind === 'image' ? fb_attach_thumb_url($key, 360) : '',
];
}
?>
<div class="uploader-box"
data-uploader-root
data-csrf="<?php echo e(fb_csrf_token()); ?>"
data-sign-url="upload.php"
data-init="<?php echo e(json_encode($init, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?>">
<?php foreach ($kinds as $kind):
$isImg = ($kind === 'image');
$maxN = (int)fb_upload_cfg($kind . '_max_per_post', 1);
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
$accept = implode(',', (array)fb_upload_cfg($kind . '_mimes', []));
$label = $isImg ? '图片' : '视频';
$desc = $isImg
? ('最多 ' . $maxN . ' 张,单张不超过 ' . fb_size_h($maxB) . ';大图会自动压缩后上传')
: ('最多 ' . $maxN . ' 个,单个不超过 ' . fb_size_h($maxB));
?>
<div class="uploader" data-uploader data-kind="<?php echo e($kind); ?>"
data-max-count="<?php echo $maxN; ?>"
data-max-bytes="<?php echo $maxB; ?>"
data-max-side="<?php echo (int)fb_upload_cfg('image_max_side', 1600); ?>"
data-accept="<?php echo e($accept); ?>"
data-label="<?php echo e($label); ?>">
<div class="up-head">
<span class="up-title"><?php echo e($label); ?> <i class="hint"><?php echo e($desc); ?></i></span>
<button type="button" class="btn up-pick">选择<?php echo e($label); ?></button>
<input type="file" class="up-file" accept="<?php echo e($accept); ?>"
<?php echo $isImg ? 'multiple' : ''; ?> tabindex="-1">
</div>
<div class="up-list"></div>
</div>
<?php endforeach; ?>
<input type="hidden" name="attachments" class="att-field" value="">
</div>
<?php
}