Files
FeedLite/lib/db.php
T

421 lines
17 KiB
PHP
Raw Normal View History

<?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 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)');
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)');
/* ---- 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;
}