data/uploads 改为由 PHP 自动创建并自补防护,不依赖部署账号建目录 (#1)

## 问题
部署时用 btops 建 data/ 与 uploads/,属主成了 btops:btops,Web 用户 www 写不进去,
安装向导直接报「目录不可写」,还得请人用 root 手工 chown —— 这不该让用户干。

## 修法:让 PHP 自己建
PHP 以 Web 用户身份运行,它建出来的目录属主天生就是 www,不需要任何 chown。
- 部署脚本不再创建 data/ uploads/(只建 assets/ lib/)
- 首次访问时 fb_db_init() 自动 mkdir data/,并立刻补防护文件
- uploads/ 由 fb_local_ensure_dir() 同样自动建 + 补防护
- 安装向导的环境体检 mkdir 之后也立刻补防护,不留空窗期

## 新增 data/ 防护文件自动生成
原先只有 uploads/ 有自动防护,data/ 没有 —— 数据库目录裸奔。
新增 fb_db_ensure_guard(),写 .htaccess(拒绝直读,禁 db/db-wal/db-shm/sqlite/php)
+ index.html 占位,带 `# fb-data-guard: vN` 版本标记,规则升级时整份重写。

## 顺带确认
站点根 .htaccess 已有全局兜底(拒绝 .db/.log/.md/.bak 等直读 + Options -Indexes),
即使某个目录暂时缺 .htaccess,/data/ 和 /data/feedback.db 也是 403。
现在是双保险,不依赖单点。

验证(WSL 真实环境):
- 全新安装 33/33、老基线 25/25、附件专项 22/22、安装向导 60/60
- data/ 与 uploads/ 均由 PHP 自动创建,属主即运行用户
- 两者 .htaccess 自动生成并带正确版本标记(v1 / v2)
This commit is contained in:
2026-11-01 15:07:53 +08:00
parent 1c2e8d5e65
commit b835afcd04
2 changed files with 57 additions and 0 deletions
+8
View File
@@ -78,6 +78,14 @@ function fb_install_checks() {
foreach ($dirs as $d => $why) { foreach ($dirs as $d => $why) {
if (!is_dir($d)) { @mkdir($d, 0755, true); } if (!is_dir($d)) { @mkdir($d, 0755, true); }
// 目录一建好就立刻补防护文件,不留"目录存在但无防护"的空窗期。
// data/ 用 fb_db_ensure_guard(),uploads/ 用 fb_local_ensure_dir(),
// 两者都在被保护目录里写 .htaccess + index.html。
if (basename($d) === 'data' && function_exists('fb_db_ensure_guard')) {
fb_db_ensure_guard($d);
} elseif (basename($d) === 'uploads' && function_exists('fb_local_ensure_dir')) {
fb_local_ensure_dir();
}
$w = is_dir($d) && is_writable($d); $w = is_dir($d) && is_writable($d);
if (!$w) { $ok = false; } if (!$w) { $ok = false; }
$shown = ($d === FB_ROOT) ? '(站点根)' : str_replace(FB_ROOT, '', $d); $shown = ($d === FB_ROOT) ? '(站点根)' : str_replace(FB_ROOT, '', $d);
+49
View File
@@ -11,7 +11,11 @@ function fb_db_init($cfg) {
$path = (string)$cfg['db_path']; $path = (string)$cfg['db_path'];
$dir = dirname($path); $dir = dirname($path);
// 目录不存在就自己建:PHP 以 Web 用户身份运行,建出来的属主天然正确,
// 不需要部署账号预先建好、也不需要事后 chown。
if (!is_dir($dir)) { @mkdir($dir, 0755, true); } if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
// 每次进来都确保防护文件在位(函数内部有版本判断,命中后几乎零开销)
fb_db_ensure_guard($dir);
$fresh = !file_exists($path); $fresh = !file_exists($path);
@@ -29,6 +33,51 @@ function fb_db_init($cfg) {
return $pdo; 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() { function fb_db() {
return fb_db_init(fb_cfg()); return fb_db_init(fb_cfg());