*/ function sn_env_checks(): array { $c = []; $add = static function (string $name, bool $ok, string $detail, bool $required = true) use (&$c): void { $c[] = ['name' => $name, 'ok' => $ok, 'detail' => $detail, 'required' => $required]; }; $add('PHP 版本 ≥ 8.0', PHP_VERSION_ID >= 80000, '当前 ' . PHP_VERSION . '(' . PHP_SAPI . ')'); $add('PDO SQLite 扩展', extension_loaded('pdo_sqlite'), extension_loaded('pdo_sqlite') ? '已启用' : '未启用,无法建库(必需)'); $add('mbstring 扩展', extension_loaded('mbstring'), extension_loaded('mbstring') ? '已启用' : '未启用,中文长度校验会失效(必需)'); $add('GD 扩展', extension_loaded('gd'), extension_loaded('gd') ? '已启用' : '未启用,无法抓取/缩放网站图标;仍可安装,但图标只能显示首字母块', false); $add('网络抓取能力', function_exists('curl_init') || (bool)ini_get('allow_url_fopen'), function_exists('curl_init') ? 'curl 可用' : ((bool)ini_get('allow_url_fopen') ? '仅 allow_url_fopen' : '都没有,无法自动抓取网站名称/图标(建议开启)')); $add('fileinfo 扩展', extension_loaded('fileinfo'), extension_loaded('fileinfo') ? '已启用' : '未启用,自定义图标的类型识别会退化'); $dirs = [ DATA_PATH => '数据目录 data/(放 SQLite 数据库)', ICON_PATH => '网站图标目录 uploads/icons/', SITE_ICON_PATH => '站点图标目录 uploads/site/', ]; foreach ($dirs as $dir => $label) { $rel = ltrim(str_replace(APP_PATH, '', $dir), '/\\'); $add($label, sn_dir_writable($dir), sn_dir_writable($dir) ? ($rel . ' 可写') : ($rel . ' 不可写,请给 Web 用户写权限')); } // 这两项只提醒,不阻断安装:即使漏了也能装,但数据文件会被公网下载 $dataHt = is_file(DATA_PATH . '/.htaccess'); $add('data/ 已禁止 Web 访问', $dataHt, $dataHt ? 'data/.htaccess 存在' : '缺少 data/.htaccess,数据库可能被直接下载(强烈建议补上)', false); return $c; } $checks = sn_env_checks(); $envOk = true; $warnOnly = []; foreach ($checks as $ck) { if (!$ck['ok']) { if ($ck['required']) { $envOk = false; } else { $warnOnly[] = $ck['name']; } } } /** * 装完**自己删除自己**。 * * 为什么不交给用户手动删:用户会忘。留着的 install.php 即使有「已有管理员就拒绝执行」 * 的守卫,也仍是攻击面(万一哪天守卫被改坏、或数据库被清空,就能重装夺权)。 * 能自动做的事不该让人记着。 * * 三级降级(删不掉也必须让站是安全的、且如实告知): * ① 目录可写 → unlink 删掉,最干净。 * ② 目录不可写但文件可写 → 把文件内容**截断成无意义的空壳**; * 访问它只会得到空响应,同样失去威胁(这是没法删时的次优解)。 * ③ 都失败 → 返回原因,页面明确提示「请手动删除」,绝不谎报成功。 * * @return array{ok:bool, mode:string, msg:string} mode: deleted|blanked|failed */ function sn_self_delete(string $file): array { if (!is_file($file)) { return ['ok' => true, 'mode' => 'deleted', 'msg' => '文件已不存在']; } // ① 正常删除(关键在**所在目录**是否可写,与文件自身权限无关) if (@unlink($file)) { return ['ok' => true, 'mode' => 'deleted', 'msg' => '已自动删除']; } // ② 删不掉 → 覆写成**纯静态 HTML**(关键:不能写成 PHP) // // ⚠️ 实测教训:原先写成 `\n
" . "安装已完成,此文件已失效,可安全删除。
\n"; if (@file_put_contents($file, $blank, LOCK_EX) !== false) { // 清掉可能存在的编译缓存,让残留文件立刻按新内容(静态)生效 if (function_exists('opcache_invalidate')) { @opcache_invalidate($file, true); } return ['ok' => true, 'mode' => 'blanked', 'msg' => '目录不可写,已覆写为无害静态页(无法执行任何代码,但仍建议手动删除)']; } // ③ 彻底失败,如实上报 $why = '未知原因'; if (!is_writable(dirname($file))) { $why = '目录 ' . dirname($file) . ' 不可写'; } elseif (!is_writable($file)) { $why = '文件本身不可写'; } return ['ok' => false, 'mode' => 'failed', 'msg' => '无法删除:' . $why]; } /* ========================================================================= * 安装状态 * ========================================================================= */ $lockFile = DATA_PATH . '/install.lock'; // 与前台共用同一个判断源(functions.php 的 app_installed),避免两边口径不一致 $hasAdmin = app_installed(); $installed = is_file($lockFile) || $hasAdmin; $err = ''; $done = false; $delResult = null; // 自删除结果(仅安装成功那一轮有值) /* ========================================================================= * 执行安装 * ========================================================================= */ if (!$installed && $_SERVER['REQUEST_METHOD'] === 'POST') { $siteName = trim((string)($_POST['site_name'] ?? '')); $u = trim((string)($_POST['username'] ?? '')); $p = (string)($_POST['password'] ?? ''); $p2 = (string)($_POST['password2'] ?? ''); if (!$envOk) { $err = '环境检查未全部通过,请先解决上面标红的问题'; } elseif ($u === '' || $p === '') { $err = '用户名和密码都不能为空'; } elseif (!preg_match('/^[A-Za-z0-9_.\-]{2,32}$/', $u)) { $err = '用户名只能用字母、数字、下划线、点、连字符,长度 2~32'; } elseif (mb_strlen($p) < 6) { $err = '密码至少 6 位(建议 8 位以上,混合大小写和数字)'; } elseif ($p !== $p2) { $err = '两次输入的密码不一致'; } else { try { $pdo = db(); // 触发建表 + 写入默认设置/搜索引擎/示例板块 // 双保险:并发下第二个请求别重复创建 if ((int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() > 0) { throw new RuntimeException('已有管理员账号,安装已中止。请直接登录。'); } $st = $pdo->prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)'); $st->execute([$u, password_hash($p, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]); if ($siteName !== '') { $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v') ->execute(['site_name', $siteName]); } // 锁文件:写不进去不算致命(已有管理员账号时本页本来就拒绝执行) @file_put_contents($lockFile, date('c') . " installed\n"); if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } $_SESSION['uid'] = (int)$pdo->lastInsertId(); $done = true; } catch (Throwable $e) { $err = '安装失败:' . $e->getMessage(); } // 自删除**刻意放在 try/catch 之外**。 // // 为什么:它是一次「清理动作」,不属于安装事务。如果在 try 里抛异常, // 会被上面 catch 兜住并写进 $err —— 而那时账号其实已经建好了, // 等于把「清理失败」误报成「安装失败」。 // sn_self_delete 内部全部用 @ 抑制且返回结构化结果,本身不会抛; // 放在外面是双重保险,语义也更清楚。 if ($done) { $delResult = sn_self_delete(__FILE__); } } } /* 装成功后统计一下库的信息(给用户看,也证明库真的建好了) */ $dbInfo = null; if ($done || $installed) { try { $pdo = db(); $cnt = static function (string $t) use ($pdo): int { return (int)$pdo->query("SELECT COUNT(*) FROM {$t}")->fetchColumn(); }; $dbInfo = [ 'path' => DB_PATH, 'size' => is_file(DB_PATH) ? (int)filesize(DB_PATH) : 0, 'groups' => $cnt('groups'), 'sites' => $cnt('sites'), 'engines' => $cnt('engines'), 'users' => $cnt('users'), ]; } catch (Throwable $e) { $dbInfo = null; } } $title = $installed ? '已安装' : '安装向导'; ?>本系统已完成初始化,安装向导不会再执行任何写操作。
= e($dbInfo['path']) ?>
安全提示:请把服务器上的 install.php 删掉。
虽然已有管理员账号时本页会拒绝执行安装,但删掉它能少一个入口。
数据库已创建,管理员账号已就绪。
= e($dbInfo['path']) ?>
✓ 安装文件已自动处理:= e($delResult['msg']) ?>
你不需要再做任何清理。
(目录不可写所以没能物理删除,内容已清空成无害空壳;若想彻底删掉,
请给站点根目录写权限后手动 rm install.php。)
⚠ 安装文件没能自动删除:= e($delResult['msg']) ?>
站点照常可用,但请手动执行:rm = e(APP_PATH) ?>/install.php
原因通常是站点根目录对 PHP 进程不可写。
后台默认已有一份示例板块和示例网站,进去之后可以随意删改、添加自己的链接。
本系统使用 SQLite:数据库是一个文件,不需要你填写数据库地址/账号/密码,向导会自动创建。
| = $ck['ok'] ? '✓' : ($ck['required'] ? '✕' : '!') ?> | = e($ck['name']) ?>= $ck['required'] ? '' : '(建议)' ?> | = e($ck['detail']) ?> |
chown -R www:www = e(APP_PATH) ?>/data = e(APP_PATH) ?>/uploads= e(DB_PATH) ?>
安装会创建数据表,并写入一份示例板块与示例网站作为起点(可在后台随意删改)。
装完请立刻删除服务器上的 install.php。