安装向导 + PHP 8.5 兼容修复
新增 install.php:全新部署时自动接管,用户只需上传文件、打开站点即进入
安装向导。安装页自己做三件事:
- 建 db/ 与 uploads/{posters,tickets,photos,avatars}/,权限调到 775
- 逐目录真写临时文件实测可写,不靠 is_writable 猜
- 建库、建管理员、写 db/.installed 锁、自删本文件
配套改动:
- config.php 把安装检查前置于建库之前,未安装一律 302 到 install.php。
原实现无条件调用 initDB(),导致「打开首页」就等于建库并种下 admin/123456。
- lib/db.php 的 initDB(\$seedDefaultAdmin = true);安装向导传 false,
管理员由用户自己设,绝不种弱口令。新增 isInstalled()。
- login.php / nav.php 接上 siteName(),站点名称不再只存不用。
- lib/settings.php 新增 siteName(),缺省回退「电影日志」。
PHP 8.5 兼容:
- 删掉 fetch_movie.php 与 lib/storage/http.php 的 curl_close()。
8.5 起该函数 deprecated,调用会往输出插一行警告,把 JSON 响应污染成
「警告 + JSON」,前端 JSON.parse 失败 → 电影名联想功能完全失灵。
- config.php 的 display_errors 由 1 改为 0(生产环境不应向浏览器吐错误),
错误仍写日志;需要调试时设 MOVIELOG_DEBUG=1 打开。
线上验证(movie.yangyq.net,PHP 8.5.12):
未安装时 / 、/login.php 均 302 → install.php;
安装页 200、体检 10/10 通过、自动建好 5 个目录;
安装后 install.php 404、管理员登录成功、首页 200 无报错;
联想接口带会话请求「流浪地球/星际穿越/霸王别姬」均返回纯 JSON(首字符 {)。
This commit is contained in:
+42
-2
@@ -6,8 +6,25 @@
|
||||
// 所有页面统一通过 require_once 'config.php' 引入本文件。
|
||||
// ============================================
|
||||
|
||||
// 错误报告策略。
|
||||
//
|
||||
// 关键:PHP 的警告/弃用提示是**直接写进响应体**的。对返回 JSON 的接口
|
||||
// (fetch_movie.php、storage_api.php 等)来说,一条 Warning 插在 JSON 前面
|
||||
// 就会让前端 JSON.parse 失败,表现为「功能静默失灵」——联想下拉空着、
|
||||
// 存储测试报连接失败,都可能是这个原因,而接口本身其实是好的。
|
||||
//
|
||||
// 本项目踩过:PHP 8.5 把 curl_close() 标为 deprecated,一行提示就把
|
||||
// 「电影名联想」整个搞挂了(接口返回的 JSON 完全正确,只是前面多了警告)。
|
||||
//
|
||||
// 因此:
|
||||
// - 永远不把错误显示给浏览器(display_errors=0);
|
||||
// - 仍然记录到日志(log_errors=1),排查时看 PHP 错误日志;
|
||||
// - 需要临时调试时,设环境变量 MOVIELOG_DEBUG=1 即可把错误显示打开。
|
||||
$__movielog_debug = getenv('MOVIELOG_DEBUG') === '1';
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_errors', $__movielog_debug ? '1' : '0');
|
||||
ini_set('log_errors', '1');
|
||||
unset($__movielog_debug);
|
||||
|
||||
session_start();
|
||||
|
||||
@@ -38,6 +55,27 @@ foreach ([POSTER_DIR, TICKET_DIR, PHOTO_DIR, AVATAR_DIR] as $dir) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 安装检查(必须在加载任何业务模块、建库之前)=====
|
||||
//
|
||||
// 未安装时:把请求引到 install.php,并立刻退出 —— 绝不能继续往下走到 initDB()。
|
||||
// 判据只看锁文件 db/.installed,由 install.php 装完时写入。
|
||||
//
|
||||
// 为什么不能「先 initDB() 再判断」:
|
||||
// initDB() 会建库建表,并在 admins 为空时插入 admin/123456。
|
||||
// 那样「打开任意页面」这个动作就等于完成了一半安装,还会把弱口令暴露出去
|
||||
// (FeedLite 踩过同一个坑:访问动作本身建库 → 「未安装」永远判不出来)。
|
||||
//
|
||||
// 例外:install.php 自身、以及 css/js 等静态资源不受此检查约束
|
||||
// (install.php 不加载本文件;静态资源由 Web 服务器直接返回,不经过 PHP)。
|
||||
if (!is_file(ROOT_DIR . '/db/.installed')) {
|
||||
// 当前脚本名(兼容子目录部署)
|
||||
$self = basename($_SERVER['SCRIPT_NAME'] ?? '');
|
||||
if ($self !== 'install.php' && PHP_SAPI !== 'cli') {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 功能模块 =====
|
||||
require_once ROOT_DIR . '/lib/db.php'; // 数据库连接与初始化
|
||||
require_once ROOT_DIR . '/lib/settings.php'; // 系统设置读写
|
||||
@@ -50,4 +88,6 @@ require_once ROOT_DIR . '/lib/reviews.php'; // 评价与评价下的讨论
|
||||
require_once ROOT_DIR . '/lib/stats.php'; // 统计与年度报告
|
||||
|
||||
// 初始化数据库
|
||||
initDB();
|
||||
// 走到这里说明已装过(上面的检查已拦截未安装的情况),
|
||||
// 因此 seedDefaultAdmin 传 false:老库本来就有人,空库也不该被塞一个 admin/123456。
|
||||
initDB(false);
|
||||
|
||||
+3
-1
@@ -79,7 +79,9 @@ function httpGet($url, $referer = '', $accept = '') {
|
||||
$body = curl_exec($ch);
|
||||
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
// 不调用 curl_close():PHP 8.0 起句柄由 GC 回收,该函数自 8.5 起被标记 deprecated。
|
||||
// 一旦调用,8.5 会往输出里插一行 Deprecated 警告,把本接口的 JSON 响应污染成
|
||||
// 「警告 + JSON」,前端 JSON.parse 直接失败 —— 联想功能就是因此挂掉的。
|
||||
|
||||
if ($body === false) {
|
||||
return ['ok' => false, 'http' => $http, 'body' => '', 'err' => $err ?: '请求失败'];
|
||||
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
<?php
|
||||
// ============================================
|
||||
// 文件: install.php
|
||||
// 说明: 安装向导。全新部署时访问站点,会被自动引到这里;
|
||||
// 填完站点名与管理员账号即完成初始化,然后进入系统。
|
||||
//
|
||||
// ⚠️ 为什么本文件**不 require config.php**:
|
||||
// config.php 末尾无条件调用 initDB(),而 initDB() 会建库、建表、
|
||||
// 并在 admins 为空时插入默认管理员 admin/123456。
|
||||
// 如果安装页也走 config.php,那么「打开安装页」这个动作本身就把库建出来了,
|
||||
// 「是否已安装」永远判不出来,安装向导永远进不去。
|
||||
// (同一类坑在 FeedLite 上踩过:e2e 因此全 FAIL、源码目录凭空多出 db 文件。)
|
||||
// 所以本文件自己定义常量、自己按需建库,绝不触发 config.php 的副作用。
|
||||
//
|
||||
// 安装完成后:
|
||||
// 1) 写 db/.installed 锁文件(该目录已被 .gitignore 排除,不会进仓库);
|
||||
// 2) 尝试自删本文件;删不掉(例如站点目录只读)也不影响安全,
|
||||
// 因为锁文件已存在,再次访问一律 404。
|
||||
// ============================================
|
||||
|
||||
// ---------- 最小引导(不加载 config.php)----------
|
||||
define('ROOT_DIR', __DIR__);
|
||||
define('DB_PATH', ROOT_DIR . '/db/movies.db');
|
||||
define('INSTALL_LOCK', ROOT_DIR . '/db/.installed');
|
||||
|
||||
define('UPLOAD_DIR', ROOT_DIR . '/uploads/');
|
||||
define('POSTER_DIR', UPLOAD_DIR . 'posters/');
|
||||
define('TICKET_DIR', UPLOAD_DIR . 'tickets/');
|
||||
define('PHOTO_DIR', UPLOAD_DIR . 'photos/');
|
||||
define('AVATAR_DIR', UPLOAD_DIR . 'avatars/');
|
||||
|
||||
// 安装期把错误显示出来,方便诊断;但出口统一包 try/catch,不把堆栈抛给用户。
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// ---------- 工具 ----------
|
||||
function esc($s) {
|
||||
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
// 字符数(不依赖 mbstring:缺 mbstring 时用正则数 UTF-8 码点,避免直接 Fatal)
|
||||
function charLen($s) {
|
||||
if (function_exists('mb_strlen')) {
|
||||
return mb_strlen($s, 'UTF-8');
|
||||
}
|
||||
if (preg_match_all('/./us', $s, $m) !== false) {
|
||||
return count($m[0]);
|
||||
}
|
||||
return strlen($s);
|
||||
}
|
||||
|
||||
// 写一条 settings(不依赖 SQLite 版本:老库不一定支持 UPSERT 语法)
|
||||
// 显式 INSERT,冲突时改 UPDATE,两条语句等效且兼容 3.20 以下。
|
||||
function putSetting(PDO $db, $name, $value) {
|
||||
$stmt = $db->prepare("SELECT COUNT(*) FROM settings WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
if ((int)$stmt->fetchColumn() > 0) {
|
||||
$up = $db->prepare("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?");
|
||||
return $up->execute([$value, $name]);
|
||||
}
|
||||
$ins = $db->prepare("INSERT INTO settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)");
|
||||
return $ins->execute([$name, $value]);
|
||||
}
|
||||
|
||||
// 已安装?——只认锁文件。锁在 = 装过了。
|
||||
// 注意:不拿「库文件存在」当判据 —— 库可能是空壳(建库失败、被人为清空),
|
||||
// 那种情况应该允许重新安装,而不是把用户挡在门外。
|
||||
function alreadyInstalled() {
|
||||
return is_file(INSTALL_LOCK);
|
||||
}
|
||||
|
||||
function abort404() {
|
||||
http_response_code(404);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
|
||||
. '<title>404 Not Found</title></head><body>'
|
||||
. '<h1>404 Not Found</h1><p>页面不存在。</p></body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------- 环境体检 ----------
|
||||
// 每项:['name'=>显示名, 'ok'=>bool, 'need'=>是否硬性要求, 'hint'=>不通过时的补救说明]
|
||||
function runChecks() {
|
||||
$checks = [];
|
||||
|
||||
// PHP 版本:代码用了 PASSWORD_DEFAULT、箭头函数等,7.4 起可跑
|
||||
$checks[] = [
|
||||
'name' => 'PHP 版本 ≥ 7.4',
|
||||
'ok' => version_compare(PHP_VERSION, '7.4.0', '>='),
|
||||
'need' => true,
|
||||
'hint' => '当前 ' . PHP_VERSION . ',请升级 PHP。',
|
||||
];
|
||||
|
||||
// 扩展:pdo_sqlite 是硬要求;openssl 用于设置项加密(缺了仍能跑,只是云密钥无法加密);
|
||||
// fileinfo 用于本机存储模式的图片内容校验;mbstring 处理中文更稳。
|
||||
$exts = [
|
||||
'pdo_sqlite' => ['PDO SQLite 驱动', true, '数据库必需。'],
|
||||
'openssl' => ['OpenSSL', true, '用于加密保存云存储密钥。'],
|
||||
'fileinfo' => ['Fileinfo', false, '本机存储模式下按内容校验图片类型;缺失则退化为扩展名判断。'],
|
||||
'mbstring' => ['Mbstring', false, '中文截断/编码处理;缺失一般不影响主流程。'],
|
||||
];
|
||||
foreach ($exts as $ext => $cfg) {
|
||||
$checks[] = [
|
||||
'name' => '扩展 ' . $cfg[0] . '(' . $ext . ')',
|
||||
'ok' => extension_loaded($ext),
|
||||
'need' => $cfg[1],
|
||||
'hint' => $cfg[2],
|
||||
];
|
||||
}
|
||||
|
||||
// 目录可写:db/(建库+密钥)、uploads/ 四个子目录(存图)
|
||||
$dirs = [
|
||||
ROOT_DIR . '/db' => '数据库目录 db/',
|
||||
POSTER_DIR => '海报目录 uploads/posters/',
|
||||
TICKET_DIR => '票根目录 uploads/tickets/',
|
||||
PHOTO_DIR => '照片目录 uploads/photos/',
|
||||
AVATAR_DIR => '头像目录 uploads/avatars/',
|
||||
];
|
||||
foreach ($dirs as $dir => $label) {
|
||||
$exists = is_dir($dir);
|
||||
$writable = $exists && is_writable($dir);
|
||||
$checks[] = [
|
||||
'name' => $label . ' 可写',
|
||||
'ok' => $writable,
|
||||
'need' => true,
|
||||
'hint' => $exists
|
||||
? '目录存在但不可写,请给 Web 用户写权限(属主 www,或 chmod 775)。'
|
||||
: '目录不存在且无法自动创建,请手动建好并授权。',
|
||||
];
|
||||
}
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
// 体检是否放行(硬性项全过)
|
||||
function checksPass(array $checks) {
|
||||
foreach ($checks as $c) {
|
||||
if ($c['need'] && !$c['ok']) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 尽力创建运行所需目录
|
||||
function ensureDirs() {
|
||||
$dirs = [ROOT_DIR . '/db', POSTER_DIR, TICKET_DIR, PHOTO_DIR, AVATAR_DIR];
|
||||
$failed = [];
|
||||
foreach ($dirs as $d) {
|
||||
if (!is_dir($d)) {
|
||||
if (!@mkdir($d, 0755, true) && !is_dir($d)) {
|
||||
$failed[] = $d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $failed;
|
||||
}
|
||||
|
||||
// ---------- 入口 ----------
|
||||
if (alreadyInstalled()) {
|
||||
abort404(); // 装过了就当它不存在
|
||||
}
|
||||
|
||||
$createdDirs = ensureDirs();
|
||||
$checks = runChecks();
|
||||
$canInstall = checksPass($checks);
|
||||
|
||||
$errors = []; // 全局错误
|
||||
$fieldErrors = []; // 字段级错误
|
||||
$old = ['site_name' => '', 'username' => ''];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$old['site_name'] = trim((string)($_POST['site_name'] ?? ''));
|
||||
$old['username'] = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
$password2 = (string)($_POST['password2'] ?? '');
|
||||
|
||||
if (!$canInstall) {
|
||||
$errors[] = '环境检查未通过,请先解决下面标红的问题再安装。';
|
||||
}
|
||||
|
||||
// 站点名:存进 settings,用于页面标题(可留空,留空用默认「电影日志」)
|
||||
if ($old['site_name'] !== '' && charLen($old['site_name']) > 40) {
|
||||
$fieldErrors['site_name'] = '站点名称最多 40 个字。';
|
||||
}
|
||||
|
||||
// 管理员账号
|
||||
if ($old['username'] === '') {
|
||||
$fieldErrors['username'] = '请填写管理员账号。';
|
||||
} elseif (!preg_match('/^[A-Za-z0-9_\x{4e00}-\x{9fa5}]{2,20}$/u', $old['username'])) {
|
||||
$fieldErrors['username'] = '账号 2–20 位,可用中文、字母、数字、下划线。';
|
||||
} elseif (in_array(strtolower($old['username']), ['admin', 'administrator', 'root'], true)
|
||||
&& $password === '123456') {
|
||||
// 明确拦住「admin + 123456」这个最容易被扫的弱口令组合
|
||||
$fieldErrors['username'] = '请勿使用 admin/123456 这类默认弱口令,换一个账号或改密码。';
|
||||
}
|
||||
|
||||
// 密码
|
||||
if ($password === '') {
|
||||
$fieldErrors['password'] = '请设置密码。';
|
||||
} elseif (charLen($password) < 6) {
|
||||
$fieldErrors['password'] = '密码至少 6 位。';
|
||||
} elseif ($password !== $password2) {
|
||||
$fieldErrors['password2'] = '两次输入的密码不一致。';
|
||||
}
|
||||
|
||||
if (!$errors && !$fieldErrors) {
|
||||
try {
|
||||
// 建表:require lib/db.php 只定义函数,不执行任何副作用。
|
||||
// initDB(false) 表示「不要种默认管理员」——管理员由本页按用户输入创建。
|
||||
require_once ROOT_DIR . '/lib/db.php';
|
||||
initDB(false);
|
||||
|
||||
$db = getDB();
|
||||
$db->beginTransaction();
|
||||
|
||||
// 双保险:库若已有管理员(例如重复安装到旧库),不再新增,直接报错让人处理
|
||||
$cnt = (int)$db->query("SELECT COUNT(*) FROM admins")->fetchColumn();
|
||||
if ($cnt > 0) {
|
||||
$db->rollBack();
|
||||
throw new RuntimeException('这个数据库里已经有管理员账号了。为避免覆盖,安装已中止。'
|
||||
. '若确实要重装,请先备份并删除 db/movies.db。');
|
||||
}
|
||||
|
||||
$hashed = password_hash($password, PASSWORD_DEFAULT);
|
||||
$stmt = $db->prepare("INSERT INTO admins (username, password) VALUES (?, ?)");
|
||||
$stmt->execute([$old['username'], $hashed]);
|
||||
$adminId = (int)$db->lastInsertId();
|
||||
|
||||
// 站点名写入 settings(表由 initDB 建好)
|
||||
if ($old['site_name'] !== '') {
|
||||
putSetting($db, 'site_name', $old['site_name']);
|
||||
}
|
||||
|
||||
// 存储方式默认 local(与 settings.php 的缺省一致)
|
||||
putSetting($db, 'storage_type', 'local');
|
||||
|
||||
// 安装完成标记
|
||||
putSetting($db, 'installed_at', date('Y-m-d H:i:s'));
|
||||
|
||||
$db->commit();
|
||||
|
||||
// 写锁文件(先写,再尝试自删;顺序反了会留下「已自删但没上锁」的窗口)
|
||||
$lockOk = @file_put_contents(
|
||||
INSTALL_LOCK,
|
||||
"installed_at=" . date('c') . "\nadmin=" . $old['username'] . "\n"
|
||||
) !== false;
|
||||
if ($lockOk) @chmod(INSTALL_LOCK, 0600);
|
||||
|
||||
// 自删:成功则以后连文件都不存在;失败也无妨,上面的锁已生效
|
||||
$selfDeleted = @unlink(__FILE__);
|
||||
|
||||
// 直接登入,省得用户再输一次
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
@session_start();
|
||||
}
|
||||
$_SESSION['admin_id'] = $adminId;
|
||||
$_SESSION['admin_username'] = $old['username'];
|
||||
$_SESSION['admin_theme'] = '';
|
||||
|
||||
// 安装成功页
|
||||
renderDone($old['site_name'], $old['username'], $lockOk, $selfDeleted);
|
||||
exit;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = '安装失败:' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 渲染 ----------
|
||||
function pageHead($title) {
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="ticket">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title><?= esc($title) ?></title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<style>
|
||||
/* 安装页自包含样式:直接复用登录页的深色玻璃语言,不依赖 themes.css
|
||||
(全新部署时主题 CSS 可能还没生效,安装页必须自身完整可看)。 */
|
||||
:root { --primary: #e67e22; --primary-rgb: 230,126,34; --primary-light: #f39c12; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; padding: 32px 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #fff;
|
||||
}
|
||||
.wrap { width: 100%; max-width: 560px; }
|
||||
.card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 24px; padding: 40px 36px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.5);
|
||||
}
|
||||
.logo { text-align: center; margin-bottom: 28px; }
|
||||
.logo .icon { font-size: 48px; display: block; margin-bottom: 6px; }
|
||||
.logo h1 { font-size: 26px; margin: 0; letter-spacing: 2px; font-weight: 700; }
|
||||
.logo p { color: rgba(255,255,255,0.5); font-size: 14px; margin: 6px 0 0; }
|
||||
h2.section {
|
||||
font-size: 15px; font-weight: 600; color: rgba(255,255,255,0.85);
|
||||
margin: 28px 0 12px; padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
h2.section:first-of-type { margin-top: 0; }
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label { display: block; font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.7); margin-bottom: 6px; }
|
||||
.field input {
|
||||
width: 100%; padding: 13px 16px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 12px; color: #fff; font-size: 16px;
|
||||
transition: all .25s ease; outline: none;
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: var(--primary);
|
||||
background: rgba(255,255,255,0.12);
|
||||
box-shadow: 0 0 0 4px rgba(var(--primary-rgb),0.15);
|
||||
}
|
||||
.field input::placeholder { color: rgba(255,255,255,0.3); }
|
||||
.field .hint { font-size: 12.5px; color: rgba(255,255,255,0.4); margin-top: 6px; }
|
||||
.field .err { font-size: 13px; color: #ff8a80; margin-top: 6px; }
|
||||
.field.bad input { border-color: rgba(231,76,60,0.7); }
|
||||
.btns { margin-top: 26px; }
|
||||
.btn {
|
||||
width: 100%; padding: 14px; border: none; border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #fff; font-size: 17px; font-weight: 600; cursor: pointer;
|
||||
transition: all .25s ease; letter-spacing: 1px;
|
||||
}
|
||||
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(var(--primary-rgb),0.3); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
.btn-ghost {
|
||||
display: inline-block; text-decoration: none; text-align: center;
|
||||
padding: 13px 22px; border-radius: 12px; color: #fff;
|
||||
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-ghost:hover { background: rgba(255,255,255,0.16); }
|
||||
.alert {
|
||||
padding: 12px 16px; border-radius: 10px; font-size: 14px; margin-bottom: 18px;
|
||||
background: rgba(231,76,60,0.18); border: 1px solid rgba(231,76,60,0.35); color: #ff8a80;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.checks { display: flex; flex-direction: column; gap: 2px; }
|
||||
.check {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 9px 12px; border-radius: 9px; font-size: 14px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.check .mark { flex: 0 0 auto; font-weight: 700; line-height: 1.5; }
|
||||
.check.ok .mark { color: #4ade80; }
|
||||
.check.warn .mark { color: #fbbf24; }
|
||||
.check.bad .mark { color: #ff6b6b; }
|
||||
.check .txt { flex: 1 1 auto; color: rgba(255,255,255,0.85); }
|
||||
.check .sub { display: block; font-size: 12.5px; color: rgba(255,255,255,0.45); margin-top: 3px; }
|
||||
.check.bad { background: rgba(231,76,60,0.12); }
|
||||
.foot { text-align: center; margin-top: 22px; color: rgba(255,255,255,0.3); font-size: 12.5px; line-height: 1.7; }
|
||||
.done-icon { text-align: center; font-size: 56px; margin-bottom: 8px; }
|
||||
.done-title { text-align: center; font-size: 22px; font-weight: 700; margin: 0 0 8px; }
|
||||
.done-sub { text-align: center; color: rgba(255,255,255,0.55); font-size: 14px; margin: 0 0 26px; }
|
||||
.kv { font-size: 14px; background: rgba(255,255,255,0.04); border-radius: 12px; padding: 14px 18px; margin-bottom: 22px; }
|
||||
.kv div { display: flex; gap: 10px; padding: 5px 0; }
|
||||
.kv b { flex: 0 0 84px; color: rgba(255,255,255,0.55); font-weight: 500; }
|
||||
.kv span { color: #fff; word-break: break-all; }
|
||||
@media (max-width: 520px) {
|
||||
.card { padding: 28px 20px; border-radius: 18px; }
|
||||
.logo h1 { font-size: 22px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<?php
|
||||
}
|
||||
|
||||
function pageFoot() {
|
||||
?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
|
||||
function renderDone($siteName, $username, $lockOk, $selfDeleted) {
|
||||
pageHead('安装完成 - 电影日志');
|
||||
$title = $siteName !== '' ? $siteName : '电影日志';
|
||||
?>
|
||||
<div class="card">
|
||||
<div class="done-icon">🎉</div>
|
||||
<h1 class="done-title">安装完成</h1>
|
||||
<p class="done-sub">已经可以开始记录观影了</p>
|
||||
|
||||
<div class="kv">
|
||||
<div><b>站点名称</b><span><?= esc($title) ?></span></div>
|
||||
<div><b>管理员</b><span><?= esc($username) ?></span></div>
|
||||
<div><b>数据文件</b><span>db/movies.db</span></div>
|
||||
<div><b>安全锁</b><span><?= $lockOk ? '已启用(db/.installed)' : '未写入,请检查 db/ 权限' ?></span></div>
|
||||
<div><b>安装入口</b><span><?= $selfDeleted ? '已自动删除' : '未能自动删除(锁文件已拦截,可手动删)' ?></span></div>
|
||||
</div>
|
||||
|
||||
<a class="btn-ghost" href="index.php" style="display:block;">进入系统 →</a>
|
||||
|
||||
<p class="foot">
|
||||
接下来建议先去「系统设置」确认图片来源存储方式(默认存本机)。
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
pageFoot();
|
||||
}
|
||||
?>
|
||||
<?php
|
||||
// 正常渲染安装表单(走完上面的 POST 分支后仍在此处,说明安装未成功或首次进入)
|
||||
pageHead('安装 - 电影日志');
|
||||
?>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<span class="icon">🎬</span>
|
||||
<h1>电影日志</h1>
|
||||
<p>首次使用,先做一点简单设置</p>
|
||||
</div>
|
||||
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert"><?= implode('<br>', array_map('esc', $errors)) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="">
|
||||
<h2 class="section">① 运行环境</h2>
|
||||
<div class="checks">
|
||||
<?php foreach ($checks as $c):
|
||||
$cls = $c['ok'] ? 'ok' : ($c['need'] ? 'bad' : 'warn');
|
||||
$mark = $c['ok'] ? '✓' : ($c['need'] ? '✕' : '!');
|
||||
?>
|
||||
<div class="check <?= $cls ?>">
|
||||
<span class="mark"><?= $mark ?></span>
|
||||
<span class="txt">
|
||||
<?= esc($c['name']) ?>
|
||||
<?php if (!$c['ok']): ?><span class="sub"><?= esc($c['hint']) ?></span><?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<h2 class="section">② 站点</h2>
|
||||
<div class="field <?= isset($fieldErrors['site_name']) ? 'bad' : '' ?>">
|
||||
<label>站点名称</label>
|
||||
<input type="text" name="site_name" maxlength="40"
|
||||
value="<?= esc($old['site_name']) ?>"
|
||||
placeholder="电影日志(留空则用默认)">
|
||||
<?php if (isset($fieldErrors['site_name'])): ?>
|
||||
<div class="err"><?= esc($fieldErrors['site_name']) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="hint">显示在登录页与导航栏,可以后面再改。</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h2 class="section">③ 管理员账号</h2>
|
||||
<div class="field <?= isset($fieldErrors['username']) ? 'bad' : '' ?>">
|
||||
<label>账号</label>
|
||||
<input type="text" name="username" autocomplete="username"
|
||||
value="<?= esc($old['username']) ?>"
|
||||
placeholder="例如 老杨">
|
||||
<?php if (isset($fieldErrors['username'])): ?>
|
||||
<div class="err"><?= esc($fieldErrors['username']) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="hint">2–20 位,可用中文、字母、数字、下划线。</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field <?= isset($fieldErrors['password']) ? 'bad' : '' ?>">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" autocomplete="new-password"
|
||||
placeholder="至少 6 位">
|
||||
<?php if (isset($fieldErrors['password'])): ?>
|
||||
<div class="err"><?= esc($fieldErrors['password']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field <?= isset($fieldErrors['password2']) ? 'bad' : '' ?>">
|
||||
<label>确认密码</label>
|
||||
<input type="password" name="password2" autocomplete="new-password"
|
||||
placeholder="再输一次">
|
||||
<?php if (isset($fieldErrors['password2'])): ?>
|
||||
<div class="err"><?= esc($fieldErrors['password2']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="btns">
|
||||
<button type="submit" class="btn" <?= $canInstall ? '' : 'disabled' ?>>
|
||||
<?= $canInstall ? '开始安装' : '请先解决环境问题' ?>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="foot">
|
||||
装好后本页面会自动删除,再次访问返回 404。<br>
|
||||
数据库与图片都在站点目录内,迁移时记得一起带走。
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
pageFoot();
|
||||
+17
-2
@@ -17,7 +17,13 @@ function getDB() {
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
function initDB() {
|
||||
//
|
||||
// $seedDefaultAdmin:admins 表为空时要不要插入默认管理员 admin/123456。
|
||||
// 默认 true —— 保持老站点、老脚本的原有行为不变(它们都靠这个账号首次登录)。
|
||||
// 安装向导传 false:管理员由用户在 install.php 里自己设,绝不种弱口令。
|
||||
// ⚠️ 这一点很关键:若安装页也走 true,则「打开安装页」就等于「库已建好且有默认账号」,
|
||||
// 弱口令会直接暴露在公网,而且「是否已安装」的判断也被这件事本身污染。
|
||||
function initDB($seedDefaultAdmin = true) {
|
||||
$db = getDB();
|
||||
|
||||
// 管理员表
|
||||
@@ -191,7 +197,8 @@ function initDB() {
|
||||
$update = $db->prepare("UPDATE viewers SET person_id = (SELECT id FROM persons WHERE persons.name = viewers.name) WHERE person_id IS NULL AND name IS NOT NULL");
|
||||
$update->execute();
|
||||
|
||||
// 默认管理员
|
||||
// 默认管理员(仅当调用方允许时;安装向导会传 false,由用户自己设账号)
|
||||
if ($seedDefaultAdmin) {
|
||||
$stmt = $db->prepare("SELECT COUNT(*) FROM admins");
|
||||
$stmt->execute();
|
||||
if ($stmt->fetchColumn() == 0) {
|
||||
@@ -200,3 +207,11 @@ function initDB() {
|
||||
$stmt->execute(['admin', $hashed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 系统是否已完成安装。
|
||||
// 判据是锁文件 db/.installed(由 install.php 写入),**不看库文件是否存在** ——
|
||||
// 空库、半成品库都该允许重新安装,用「库在不在」当判据会把用户永久挡在门外。
|
||||
function isInstalled() {
|
||||
return is_file(ROOT_DIR . '/db/.installed');
|
||||
}
|
||||
|
||||
@@ -244,6 +244,14 @@ function getSetting($name, $default = null) {
|
||||
return array_key_exists($name, $cache) ? $cache[$name] : $default;
|
||||
}
|
||||
|
||||
// 站点名称(安装向导里填的那个)。
|
||||
// 没填过 / 装的是老库(settings 里没有这一项)时,退回默认「电影日志」——
|
||||
// 这样任何页面的标题都不会因为缺这一项而空掉。
|
||||
function siteName() {
|
||||
$v = trim((string)getSetting('site_name', ''));
|
||||
return $v !== '' ? $v : '电影日志';
|
||||
}
|
||||
|
||||
// 读取布尔型设置
|
||||
function getSettingBool($name, $default = false) {
|
||||
$v = getSetting($name, null);
|
||||
|
||||
@@ -104,7 +104,8 @@ function storageHttp($method, $url, array $headers = [], $body = null, $timeout
|
||||
$err = curl_error($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
// 不调用 curl_close():PHP 8.0 起句柄由 GC 回收;8.5 起该函数被标记 deprecated,
|
||||
// 调用会在输出里插入警告,污染本模块返回给前端的 JSON。
|
||||
|
||||
if ($out === false) {
|
||||
return [
|
||||
|
||||
@@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>电影全纪录 - 登录</title>
|
||||
<title><?= h(siteName()) ?> - 登录</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<?= themesCssLink() ?>
|
||||
<style>
|
||||
@@ -177,7 +177,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<div class="login-container">
|
||||
<div class="login-logo">
|
||||
<span class="icon">🎬</span>
|
||||
<h1>电影全纪录</h1>
|
||||
<h1><?= h(siteName()) ?></h1>
|
||||
<p>记录每一帧感动</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ $current_page = basename($_SERVER['PHP_SELF']);
|
||||
右侧折叠菜单里的「首页」要点两下(先展开再点),这里一下就到。 -->
|
||||
<a href="index.php" class="nav-brand" title="回到首页" aria-label="回到首页">
|
||||
<span class="brand-icon">🎬</span>
|
||||
<span class="brand-name">电影日志</span>
|
||||
<span class="brand-name"><?= h(siteName()) ?></span>
|
||||
</a>
|
||||
<div class="nav-actions">
|
||||
<span class="nav-user">
|
||||
|
||||
Reference in New Issue
Block a user