Files
FeedLite/lib/util.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

308 lines
11 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
/**
* 通用工具函数
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** HTML 转义(所有用户内容输出前必须经过它) */
function e($s) {
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** 相对时间显示 */
function fb_fmt_time($ts) {
$ts = (int)$ts;
$diff = time() - $ts;
if ($diff < 0) { $diff = 0; }
if ($diff < 60) { return '刚刚'; }
if ($diff < 3600) { return ((int)floor($diff / 60)) . ' 分钟前'; }
if ($diff < 86400) { return ((int)floor($diff / 3600)) . ' 小时前'; }
if ($diff < 604800) { return ((int)floor($diff / 86400)) . ' 天前'; }
if (date('Y', $ts) === date('Y')) { return date('m-d H:i', $ts); }
return date('Y-m-d H:i', $ts);
}
/** 访客 IP(兼容反向代理 / CDN) */
function fb_client_ip() {
$keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
foreach ($keys as $k) {
if (!empty($_SERVER[$k])) {
$v = explode(',', (string)$_SERVER[$k]);
$v = trim($v[0]);
if (filter_var($v, FILTER_VALIDATE_IP)) { return $v; }
}
}
return '0.0.0.0';
}
/** 访客 IP 的加盐哈希(不保存明文 IP) */
function fb_ip_hash() {
return hash('sha256', fb_client_ip() . '|' . (string)fb_cfg('ip_salt'));
}
/** 站点基地址(优先取配置,否则自动探测) */
function fb_base_url() {
$b = (string)fb_cfg('base_url', '');
if ($b !== '') { return rtrim($b, '/'); }
$https = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
$scheme = $https ? 'https' : 'http';
$host = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : 'localhost';
$script = isset($_SERVER['SCRIPT_NAME']) ? (string)$_SERVER['SCRIPT_NAME'] : '/index.php';
$dir = rtrim(str_replace('\\', '/', dirname($script)), '/');
return $scheme . '://' . $host . $dir;
}
/** 跳转并结束 */
function fb_redirect($url) {
header('Location: ' . $url);
exit;
}
/** 分类 / 状态 */
function fb_categories() { return (array)fb_cfg('categories', []); }
function fb_category_label($k) {
$c = fb_categories();
return isset($c[$k]) ? $c[$k] : '其他';
}
function fb_statuses() { return (array)fb_cfg('statuses', []); }
function fb_status_label($k) {
$s = fb_statuses();
return isset($s[$k]) ? $s[$k] : (string)$k;
}
/** CSRF */
function fb_csrf_token() {
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf'];
}
function fb_csrf_ok($t) {
return !empty($_SESSION['csrf']) && is_string($t) && hash_equals($_SESSION['csrf'], $t);
}
/** 频率限制 */
function fb_rate_check($action, $limit) {
$pdo = fb_db();
$since = time() - 3600;
$st = $pdo->prepare('SELECT COUNT(*) FROM rate_limit WHERE ip_hash=? AND action=? AND created_at>=?');
$st->execute([fb_ip_hash(), $action, $since]);
return ((int)$st->fetchColumn()) < (int)$limit;
}
function fb_rate_hit($action) {
$pdo = fb_db();
$st = $pdo->prepare('INSERT INTO rate_limit(ip_hash, action, created_at) VALUES(?,?,?)');
$st->execute([fb_ip_hash(), $action, time()]);
// 顺手清理 2 小时前的记录
if (random_int(1, 20) === 1) {
$pdo->exec('DELETE FROM rate_limit WHERE created_at < ' . (time() - 7200));
}
}
/** 文本长度(按字符计) */
function fb_len($s) {
return function_exists('mb_strlen') ? mb_strlen((string)$s, 'UTF-8') : strlen((string)$s);
}
/** 截断文本 */
function fb_excerpt($s, $len = 160) {
$s = trim(preg_replace('/\s+/u', ' ', (string)$s));
if (fb_len($s) <= $len) { return $s; }
return (function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len)) . '…';
}
/** 校验昵称:1~24 字符,不含换行 */
function fb_valid_name($s) {
$s = trim((string)$s);
if ($s === '' || fb_len($s) > 24) { return false; }
if (preg_match('/[\r\n\t]/', $s)) { return false; }
return true;
}
/** 校验正文:2~5000 字符 */
function fb_valid_body($s) {
$s = trim((string)$s);
$n = fb_len($s);
return ($n >= 2 && $n <= 5000);
}
/** 构建当前查询串(保留筛选条件) */
function fb_qs(array $over = []) {
$q = $_GET;
foreach ($over as $k => $v) {
if ($v === null) { unset($q[$k]); } else { $q[$k] = $v; }
}
$q = array_filter($q, function ($v) { return $v !== '' && $v !== null; });
return $q ? ('?' . http_build_query($q)) : '';
}
/* ---------------- 审核(先审后显) ---------------- */
/** 审核总开关:开启后新提交的问题默认不公开,需管理员审核通过才显示 */
function fb_moderation_on() {
return (bool)fb_cfg('moderation.enabled', true);
}
/** 当前访问者是否已登录审核后台(admin.php 登录后置位) */
function fb_is_admin() {
return !empty($_SESSION['fb_admin']) && $_SESSION['fb_admin'] === 1;
}
/** 该问题对当前访客可见吗?(管理员可看全部) */
function fb_thread_public(array $thread) {
if (!fb_moderation_on()) { return true; }
if (isset($thread['visible']) && (int)$thread['visible'] === 1) { return true; }
return fb_is_admin();
}
/* ---------------- 站点设置(数据库覆盖 config.php) ----------------
* 后台改的设置存在 settings 表,读取时覆盖 config.php 的同名项;
* config.php 因此变成「出厂默认值」,部署后不必再手工改它。
*/
/** 允许在后台修改的设置键(白名单,防止改到 db_path 之类) */
function fb_setting_keys() {
return [
'site_name', 'site_desc',
'attachment.storage',
'upload.image_max_per_post', 'upload.image_max_bytes',
'upload.video_max_per_post', 'upload.video_max_bytes',
'upload.total_cap_bytes', 'upload.retention_days',
'upload.upload_per_hour', 'upload.image_max_side',
'oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.enabled',
'oss.credential_source', 'oss.access_key', 'oss.secret_key',
'oss.domain', 'oss.visibility', 'oss.prefix', 'oss.url_scheme',
'oss.verify', 'oss.image_process', 'oss.use_internal_endpoint',
'moderation.enabled', 'moderation.close_mail_subject',
'admin.password_hash',
];
}
/**
* 全部设置(扁平:'oss.bucket' => 值)。带进程内缓存;$reload=true 强制重读。
* 与 fb_db() 之间可能互相调用,用 $loading 防止递归。
*/
function fb_settings_flat($reload = false) {
static $flat = null;
static $loading = false;
if ($flat !== null && !$reload) { return $flat; }
if ($loading) { return []; }
$loading = true;
$out = [];
try {
$pdo = fb_db();
$rows = $pdo->query('SELECT key, value FROM settings')->fetchAll();
foreach ($rows as $r) {
$k = (string)$r['key'];
$v = json_decode((string)$r['value'], true);
if (json_last_error() !== JSON_ERROR_NONE) { $v = (string)$r['value']; }
$out[$k] = $v;
}
} catch (Throwable $e) {
$out = [];
}
$loading = false;
$flat = $out;
return $flat;
}
/** 把扁平的 'a.b.c' => v 转成嵌套数组,供 fb_cfg 与文件配置合并 */
function fb_setting_tree() {
$tree = [];
foreach (fb_settings_flat() as $k => $v) {
$parts = explode('.', (string)$k);
$cur =& $tree;
foreach ($parts as $i => $p) {
if ($i === count($parts) - 1) { $cur[$p] = $v; }
else {
if (!isset($cur[$p]) || !is_array($cur[$p])) { $cur[$p] = []; }
$cur =& $cur[$p];
}
}
unset($cur);
}
return $tree;
}
/** 读单个设置(未经白名单限制,仅用于内部读取) */
function fb_setting($key, $default = null) {
$flat = fb_settings_flat();
return array_key_exists($key, $flat) ? $flat[$key] : $default;
}
/** 写单个设置 @return bool 是否成功(不在白名单里会失败) */
function fb_set_setting($key, $value) {
if (!in_array((string)$key, fb_setting_keys(), true)) { return false; }
$pdo = fb_db();
$st = $pdo->prepare('INSERT OR REPLACE INTO settings(key, value, updated_at) VALUES(?,?,?)');
$st->execute([(string)$key, json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), time()]);
// 立刻失效:本进程内再读 fb_cfg 也要拿到新值(后台保存后同页要显示新状态)
fb_settings_flat(true);
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
return true;
}
/** 删除单个设置(回落为 config.php 里的出厂值) @return bool */
function fb_unset_setting($key) {
if (!in_array((string)$key, fb_setting_keys(), true)) { return false; }
$pdo = fb_db();
$st = $pdo->prepare('DELETE FROM settings WHERE key = ?');
$st->execute([(string)$key]);
// 同样立刻失效,保证后续 fb_cfg 读到的是一致状态
fb_settings_flat(true);
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
return true;
}
/* ---------------- 管理员登录 ---------------- */
/** 后台会话的有效期(秒):超时自动退出 */
function fb_admin_idle_ttl() {
return 7200;
}
/** 已登录且在有效期内(每次访问续期) */
function fb_admin_logged_in() {
if (!fb_is_admin()) { return false; }
$at = isset($_SESSION['fb_admin_at']) ? (int)$_SESSION['fb_admin_at'] : 0;
if ($at <= 0 || (time() - $at) > fb_admin_idle_ttl()) {
unset($_SESSION['fb_admin'], $_SESSION['fb_admin_at']);
return false;
}
$_SESSION['fb_admin_at'] = time();
return true;
}
/** 置位登录态 */
function fb_admin_login() {
$_SESSION['fb_admin'] = 1;
$_SESSION['fb_admin_at'] = time();
@session_regenerate_id(true);
}
/**
* 校验管理员口令。
* 后台改过口令后存的是 password_hash;没改过就用 config.php 的 admin_password。
*/
function fb_admin_verify_password($pw) {
$pw = (string)$pw;
$hash = (string)fb_setting('admin.password_hash', '');
if ($hash !== '') { return password_verify($pw, $hash); }
$cfg = (string)fb_cfg('admin_password', '');
return $cfg !== '' && hash_equals($cfg, $pw);
}
/** 修改管理员口令(存哈希到数据库,立即生效) */
function fb_admin_set_password($pw) {
return fb_set_setting('admin.password_hash', password_hash((string)$pw, PASSWORD_DEFAULT));
}
/** 写一条操作日志 */
function fb_mod_log($action, $threadId = null, $detail = '') {
try {
$pdo = fb_db();
$st = $pdo->prepare('INSERT INTO mod_log(thread_id, action, detail, created_at, ip_hash) VALUES(?,?,?,?,?)');
$st->execute([$threadId ? (int)$threadId : null, (string)$action, (string)$detail, time(), fb_ip_hash()]);
} catch (Throwable $e) { /* 日志失败不影响主流程 */ }
}