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

203 lines
7.3 KiB
PHP

<?php
/**
* SQLite 数据库初始化与迁移
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 打开(或创建)数据库连接,单例 */
function fb_db_init($cfg) {
static $pdo = null;
if ($pdo instanceof PDO) { return $pdo; }
$path = (string)$cfg['db_path'];
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$fresh = !file_exists($path);
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');
fb_db_migrate($pdo);
if ($fresh) { @chmod($path, 0640); }
return $pdo;
}
/** 便捷访问器 */
function fb_db() {
return fb_db_init(fb_cfg());
}
/** 建表(幂等) */
function fb_db_migrate(PDO $pdo) {
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other',
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
author_phone TEXT,
status TEXT NOT NULL DEFAULT 'open',
reply_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ip_hash TEXT
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL,
body TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
ip_hash TEXT,
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_replies_thread ON replies(thread_id, created_at)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS notify_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL,
reply_id INTEGER NOT NULL,
to_email TEXT NOT NULL,
created_at INTEGER NOT NULL,
ok INTEGER NOT NULL DEFAULT 0,
tries INTEGER NOT NULL DEFAULT 0,
err TEXT
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_notify_thread ON notify_log(thread_id, to_email)');
// 兼容早期建库:确保 notify_log.tries 存在
$cols = $pdo->query('PRAGMA table_info(notify_log)')->fetchAll();
$has = false;
foreach ($cols as $c) { if (isset($c['name']) && $c['name'] === 'tries') { $has = true; break; } }
if (!$has) { $pdo->exec('ALTER TABLE notify_log ADD COLUMN tries INTEGER NOT NULL DEFAULT 0'); }
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL,
reply_id INTEGER,
kind TEXT NOT NULL DEFAULT 'image',
oss_key TEXT NOT NULL,
mime TEXT,
size INTEGER NOT NULL DEFAULT 0,
width INTEGER,
height INTEGER,
orig_name TEXT,
seq INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE,
FOREIGN KEY(reply_id) REFERENCES replies(id) ON DELETE CASCADE
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_thread ON attachments(thread_id, seq)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_reply ON attachments(reply_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_age ON attachments(created_at)');
// 代理读取要按对象键反查(media.php 的鉴权),必须有索引否则每次读图都全表扫描
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_key ON attachments(oss_key)');
/* ---- 待入库附件 ----
* 私有桶 + 内网 Endpoint 下,附件一律经 media.php 代理输出,而代理必须鉴权。
* 但「上传成功」发生在「表单提交入库」之前 —— 前端上传完就要立刻显示缩略图,
* 此时 attachments 表里还没有这条记录。若只认 attachments 表,缩略图必然 403。
* 所以上传成功时先在这张表登记一条,media.php 认「已入库 或 本人刚上传的待入库」,
* 提交表单后由 fb_attach_save() 转正,过期条目由 cron.php 清理。
*/
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS pending_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
oss_key TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'image',
mime TEXT,
size INTEGER NOT NULL DEFAULT 0,
ip_hash TEXT,
created_at INTEGER NOT NULL
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_key ON pending_attachments(oss_key)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_age ON pending_attachments(created_at)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS rate_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_hash TEXT NOT NULL,
action TEXT NOT NULL,
created_at INTEGER NOT NULL
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_rl ON rate_limit(ip_hash, action, created_at)');
/* ---- 站点设置(后台可改,覆盖 config.php 的同名项)----
* 只存「后台允许修改」的键,见 util.php 的 fb_setting_keys()。
* value 存 JSON,读取时自动还原类型。
*/
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at INTEGER
);
SQL
);
/* ---- 管理员操作日志 ---- */
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS mod_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER,
action TEXT NOT NULL,
detail TEXT,
created_at INTEGER NOT NULL,
ip_hash TEXT
);
SQL
);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_thread ON mod_log(thread_id, created_at DESC)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_time ON mod_log(created_at DESC)');
// ---- 审核(先审后显)----
// visible:0=待审核/未公开,1=审核通过已公开
// mod_note:审核说明(关闭时写给提问人的解释,会随邮件发出)
// moderated_at:审核时间
$tcols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
$tnames = [];
foreach ($tcols as $c) { if (isset($c['name'])) { $tnames[(string)$c['name']] = true; } }
if (!isset($tnames['visible'])) {
$pdo->exec('ALTER TABLE threads ADD COLUMN visible INTEGER NOT NULL DEFAULT 0');
// 功能上线前已存在的内容视为"已审核通过",保持原来的可见性
$pdo->exec('UPDATE threads SET visible = 1');
}
if (!isset($tnames['mod_note'])) {
$pdo->exec('ALTER TABLE threads ADD COLUMN mod_note TEXT');
}
if (!isset($tnames['moderated_at'])) {
$pdo->exec('ALTER TABLE threads ADD COLUMN moderated_at INTEGER');
}
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_visible ON threads(visible, created_at DESC)');
}