Files
FeedLite/lib/db.php
T
laoyang 738c6c09ff 多软件区分:邮件按软件换色+图标+软件名;补提交回执邮件
用户要求:多软件并存时,用「颜色 + 图标 + 名字」三重区分,
邮件也要跟着所属软件变,一眼能看出是哪款软件的来信。

1. software 表加 icon / download_url 字段(幂等迁移)
   - icon 存单个字符(emoji / 汉字 / 字母),不存图片路径:
     前端与邮件用同一个字符渲染,天然一致,不用再管一套上传与权限
   - 没配 icon 时取名首字兜底,任何地方都不出现空图标
   - 后台软件编辑加图标快选(12 个常用 emoji)

2. 邮件配色改为跟随软件(fb_mail_brand 取 fb_software_colors)
   - 头部色条、按钮、引用块色条、徽标全部跟着软件走
   - 邮件头部改为「图标色块 + 软件名」,下面再小字标站点名
   - 主题行统一加【软件名】前缀,不点开就能分辨

3. 新增 receipt 事件:提交后立即给提问人发「已收到、待审核、请耐心等待」
   - 独立开关 notify.receipt_enabled(与「回复通知」是两件事)
   - 新增 7 处调用前的静默 return 也一并写日志

4. reply 邮件补上下文:原提问摘要 + 本次回复,像博客评论通知
5. status=resolved 文案改为「已完成并上线」,配了下载地址时给下载按钮

验证:多软件配色/图标/前缀全部通过(含反向验证),XSS 转义通过,
空图标兜底通过;页面顶栏、页头、选择器图标渲染正确。
2026-11-03 08:00:50 +08:00

452 lines
19 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
/**
* 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);
// 目录不存在就自己建:PHP 以 Web 用户身份运行,建出来的属主天然正确,
// 不需要部署账号预先建好、也不需要事后 chown。
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
// 每次进来都确保防护文件在位(函数内部有版本判断,命中后几乎零开销)
fb_db_ensure_guard($dir);
$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;
}
/**
* 确保数据库目录存在防护文件(.htaccess 拒绝直读 + index.html 占位)。
*
* 为什么不靠部署时上传:data/ 是可写目录,部署账号建它会让属主变成部署账号,
* Web 用户反而写不进去。让 PHP 自己建 + 自己补防护,属主永远正确。
* 版本号机制与 uploads/ 一致:规则串里埋 `# fb-data-guard: vN`,
* 低于当前版本就整份重写,避免老站点留着没防护的旧文件。
*/
define('FB_DATA_GUARD_VER', 1);
function fb_data_guard_rule() {
return "# 数据库目录(由程序自动生成,请勿删除)\n"
. "# fb-data-guard: v" . FB_DATA_GUARD_VER . "\n"
. "# ★ 一律禁止 Web 直接访问:数据库文件绝不能被下载\n"
. "Options -Indexes\n\n"
. "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
. "<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n\n"
. "<FilesMatch \"\\.(db|db-wal|db-shm|sqlite|sqlite3|php|htaccess)$\">\n"
. " <IfModule mod_authz_core.c>\n Require all denied\n </IfModule>\n"
. " <IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n </IfModule>\n"
. "</FilesMatch>\n";
}
function fb_db_ensure_guard($dir) {
if (!is_dir($dir)) { return false; }
$ht = $dir . '/.htaccess';
$need = true;
if (is_file($ht)) {
$head = @file_get_contents($ht, false, null, 0, 512);
if ($head !== false && strpos($head, '# fb-data-guard: v' . FB_DATA_GUARD_VER) !== false) {
$need = false;
}
}
if ($need) {
@file_put_contents($ht, fb_data_guard_rule());
@chmod($ht, 0644);
}
$idx = $dir . '/index.html';
if (!is_file($idx)) {
@file_put_contents($idx, '');
@chmod($idx, 0644);
}
return true;
}
/** 便捷访问器 */
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,
to_email TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT 'reply',
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)');
fb_db_migrate_taxonomy($pdo);
}
/**
* 软件 / 分类 / 状态 三张表(全部可在后台配置,不再写死在配置文件里)
*
* 结构:软件 → 分类(两级)。分类属于某个软件,不同软件可以有同名但互不相干的分类。
* 幂等:每次请求都会跑,靠 IF NOT EXISTS 与「空表才灌初始数据」保证重复执行结果一致。
*/
function fb_db_migrate_taxonomy(PDO $pdo, $cfg = null) {
/* ---- 软件 ---- */
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS software (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
name TEXT NOT NULL,
note TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
SQL
);
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_sw_slug ON software(slug)');
/* ---- 分类(属于某个软件)---- */
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
software_id INTEGER NOT NULL,
slug TEXT NOT NULL,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(software_id) REFERENCES software(id) ON DELETE CASCADE
);
SQL
);
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_cat_sw_slug ON categories(software_id, slug)');
/* ---- 状态 ---- */
// key_name 是 threads.status 里存的值;user_note 是发给用户看的解释(进邮件正文)
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS statuses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_name TEXT NOT NULL,
name TEXT NOT NULL,
user_note TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
);
SQL
);
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_st_key ON statuses(key_name)');
/* ---- notify_log 增加 reason(区分因为什么发的信,工单 #2)---- */
$nlCols = $pdo->query('PRAGMA table_info(notify_log)')->fetchAll();
$nlNames = [];
foreach ($nlCols as $c3) { if (isset($c3['name'])) { $nlNames[(string)$c3['name']] = true; } }
if (!isset($nlNames['reason'])) {
// 老库的 reply_id 是 NOT NULL,而「新反馈通知」没有回复 id,
// 加列的同时把它放宽为可空,否则插入会直接失败。
// SQLite 不支持 ALTER COLUMN,只能重建表。
$pdo->exec('ALTER TABLE notify_log ADD COLUMN reason TEXT NOT NULL DEFAULT \'reply\'');
}
/* ---- 软件增加主题色(用于前台按软件区分视觉)---- */
$swCols = $pdo->query('PRAGMA table_info(software)')->fetchAll();
$swNames = [];
foreach ($swCols as $c2) { if (isset($c2['name'])) { $swNames[(string)$c2['name']] = true; } }
if (!isset($swNames['color'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
}
/* ---- 软件增加图标(多软件并存时靠「图标 + 名字 + 颜色」三重区分)----
* 存一个字符即可:优先 emoji(日历 📅 / 锁 🔐 / 密码 🔑),也接受单个汉字或字母。
* 不存图片路径,避免再管一套上传 / 存储 / 权限 —— 邮件里也用同一个字符渲染,
* 前端与邮件天然一致,不会出现「页面有图标、邮件没有」的割裂。 */
if (!isset($swNames['icon'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN icon TEXT NOT NULL DEFAULT ''");
}
/* ---- 软件增加下载地址(状态「已解决」的邮件里给用户一个去下新版的入口)---- */
if (!isset($swNames['download_url'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN download_url TEXT NOT NULL DEFAULT ''");
}
/* ---- threads 增加两列(老 category 文本列保留,不删)---- */
$cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
$names = [];
foreach ($cols as $c) { if (isset($c['name'])) { $names[(string)$c['name']] = true; } }
if (!isset($names['software_id'])) {
// 不用 REFERENCES:SQLite 加列时外键约束对既有行不友好,改由应用层保证
$pdo->exec('ALTER TABLE threads ADD COLUMN software_id INTEGER');
}
if (!isset($names['category_id'])) {
$pdo->exec('ALTER TABLE threads ADD COLUMN category_id INTEGER');
}
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_sw ON threads(software_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cid ON threads(category_id)');
/* ================= 初始数据(空表才灌,幂等) ================= */
$now = time();
$swId = 0;
// 1) 软件:空表时建一个默认软件
if ((int)$pdo->query('SELECT COUNT(*) FROM software')->fetchColumn() === 0) {
$siteName = '';
try { $siteName = (string)fb_cfg('site_name', ''); } catch (\Throwable $e) { $siteName = ''; }
if (trim($siteName) === '') { $siteName = '我的软件'; }
$st = $pdo->prepare('INSERT INTO software(slug, name, note, sort_order, enabled, created_at)
VALUES(?,?,?,?,1,?)');
$st->execute(['default', $siteName, '默认软件,可在后台改名', 0, $now]);
$swId = (int)$pdo->lastInsertId();
} else {
$swId = (int)$pdo->query('SELECT id FROM software ORDER BY sort_order, id LIMIT 1')->fetchColumn();
}
// 2) 分类:空表时把配置文件里的老 categories 原样搬进来(保住既有五个分类)
if ((int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn() === 0 && $swId > 0) {
$old = [];
try { $old = (array)fb_cfg('categories', []); } catch (\Throwable $e) { $old = []; }
if (!$old) {
// 出厂分类:与改造前配置里的五个完全一致,一个都不能少
$old = [
'usage' => '使用问题',
'bug' => 'Bug 反馈',
'feature' => '功能建议',
'sync' => '同步问题',
'other' => '其他',
];
}
$i = 0;
$st = $pdo->prepare('INSERT INTO categories(software_id, slug, name, sort_order, enabled, created_at)
VALUES(?,?,?,?,1,?)');
foreach ($old as $k => $label) {
$slug = fb_sw_slugify($k);
if ($slug === '') { $slug = 'c' . $i; }
$st->execute([$swId, $slug, (string)$label, $i, $now]);
$i++;
}
}
// 3) 状态:空表时灌 5 个(4 个原有 + 新增「不予考虑」)
if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) {
$rows = [
['open', '待处理', '您提交的反馈已收到,我们正在处理中。', 0],
['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 1],
['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 2],
['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 3],
['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢你的建议。', 4],
];
$st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
VALUES(?,?,?,?,1)');
foreach ($rows as $r) { $st->execute($r); }
}
// 4) 回填老帖子:software_id / category_id(只填 NULL 的,重复执行无副作用)
if ($swId > 0) {
$pdo->prepare('UPDATE threads SET software_id = ? WHERE software_id IS NULL')->execute([$swId]);
}
$need = $pdo->prepare('SELECT id, category FROM threads WHERE category_id IS NULL AND software_id = ?');
$need->execute([$swId]);
$rows = $need->fetchAll();
if ($rows) {
$find = $pdo->prepare('SELECT id FROM categories WHERE software_id = ? AND slug = ? LIMIT 1');
$set = $pdo->prepare('UPDATE threads SET category_id = ? WHERE id = ?');
foreach ($rows as $r) {
$slug = fb_sw_slugify((string)$r['category']);
$find->execute([$swId, $slug]);
$cid = $find->fetchColumn();
if ($cid !== false && $cid !== null) { $set->execute([(int)$cid, (int)$r['id']]); }
}
}
}
/** 生成 URL 友好的 slug(只留小写字母数字和连字符) */
/**
* 生成 URL 友好的 slug(只留小写字母数字和连字符)。
*
* ⚠️ 纯中文名(如「同步日历」)过滤后是空串。早期调用方拿到空串就报
* 「请填写英文标识」,但界面上写的是「留空自动生成」—— 自相矛盾。
* 所以这里再加一层兜底:拿不到 ASCII 就退化成 前缀+序号,
* 保证「留空」真的能自动生成,不用用户去猜。
*
* @param string $s 原始名称
* @param string $prefix 兜底前缀,如 'sw' / 'cat';传空串表示不兜底
* @param int $seq 兜底序号(调用方传"当前最大 id + 1")
*/
function fb_sw_slugify($s, $prefix = '', $seq = 1) {
$s = strtolower(trim((string)$s));
$s = preg_replace('/[^a-z0-9_-]+/', '-', $s);
$s = trim((string)$s, '-');
$s = $s === '' ? '' : substr($s, 0, 40);
if ($s === '' && $prefix !== '') {
$s = $prefix . max(1, (int)$seq);
}
return $s;
}