新增 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(首字符 {)。
147 lines
5.4 KiB
PHP
147 lines
5.4 KiB
PHP
<?php
|
||
// ============================================
|
||
// 文件: lib/storage/http.php
|
||
// 说明: 对象存储驱动共用的 HTTP 客户端与签名工具函数
|
||
// ============================================
|
||
|
||
// 该路径是否允许当前请求访问。
|
||
//
|
||
// 宝塔等环境会给站点加 .user.ini:open_basedir=/站点目录/:/tmp/。
|
||
// 此时对 /etc/ssl/... 这类"站外路径"调用 is_file() 会抛
|
||
// Warning: is_file(): open_basedir restriction in effect
|
||
// 而站点开着 display_errors,警告就会被打进响应体——对返回 JSON 的接口
|
||
// 是致命的(前端 JSON.parse 直接失败)。所以探测前先自己判断一遍。
|
||
function storagePathAllowed($path) {
|
||
$path = str_replace('\\', '/', trim((string)$path));
|
||
if ($path === '') return false;
|
||
|
||
$open = (string)ini_get('open_basedir');
|
||
if ($open !== '') {
|
||
$allowed = false;
|
||
foreach (explode(PATH_SEPARATOR, $open) as $dir) {
|
||
$dir = rtrim(str_replace('\\', '/', trim($dir)), '/');
|
||
if ($dir === '') continue;
|
||
if ($path === $dir || strpos($path, $dir . '/') === 0) {
|
||
$allowed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!$allowed) return false;
|
||
}
|
||
|
||
// 仍在允许目录内,也压掉可能的名权限/符号链接警告
|
||
return @is_file($path) && @is_readable($path);
|
||
}
|
||
|
||
// 定位系统 CA 证书,供 SSL 校验使用(找不到时返回 null,交由 curl 自行处理)
|
||
function storageCaBundle() {
|
||
static $ca = false;
|
||
if ($ca !== false) return $ca;
|
||
$candidates = [
|
||
ini_get('curl.cainfo'),
|
||
ini_get('openssl.cafile'),
|
||
'/etc/ssl/certs/ca-certificates.crt',
|
||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||
'/etc/ssl/cert.pem',
|
||
];
|
||
foreach ($candidates as $path) {
|
||
if (storagePathAllowed($path)) {
|
||
$ca = (string)$path;
|
||
return $ca;
|
||
}
|
||
}
|
||
$ca = null;
|
||
return $ca;
|
||
}
|
||
|
||
/**
|
||
* 发起 HTTP 请求。
|
||
*
|
||
* @param string $method GET / PUT / POST / DELETE / HEAD
|
||
* @param string $url 完整 URL
|
||
* @param array $headers 形如 ['Content-Type: image/jpeg', ...]
|
||
* @param string|null $body 请求体
|
||
* @param int $timeout 总超时(秒)
|
||
* @param bool $insecure 是否跳过 SSL 证书校验(默认否)
|
||
* @param int|null $connectTimeout 连接阶段超时(秒)。默认由总超时推算;
|
||
* 想"快速失败"的地方(比如探测内网 endpoint)显式给一个很小的值。
|
||
*
|
||
* @return array ['ok'=>bool,'status'=>int,'headers'=>array,'body'=>string,'error'=>string]
|
||
*/
|
||
function storageHttp($method, $url, array $headers = [], $body = null, $timeout = 30, $insecure = false, $connectTimeout = null) {
|
||
$respHeaders = [];
|
||
$ch = curl_init();
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_URL => $url,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||
CURLOPT_HTTPHEADER => $headers,
|
||
CURLOPT_CONNECTTIMEOUT => $connectTimeout === null
|
||
? min(10, max(3, $timeout))
|
||
: max(1, (int)$connectTimeout),
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_FOLLOWLOCATION => false,
|
||
CURLOPT_SSL_VERIFYPEER => !$insecure,
|
||
CURLOPT_SSL_VERIFYHOST => $insecure ? 0 : 2,
|
||
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) {
|
||
$pos = strpos($line, ':');
|
||
if ($pos !== false) {
|
||
$name = strtolower(trim(substr($line, 0, $pos)));
|
||
$respHeaders[$name] = trim(substr($line, $pos + 1));
|
||
}
|
||
return strlen($line);
|
||
},
|
||
]);
|
||
$ca = storageCaBundle();
|
||
if ($ca && !$insecure) {
|
||
curl_setopt($ch, CURLOPT_CAINFO, $ca);
|
||
}
|
||
if ($body !== null) {
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||
}
|
||
|
||
$out = curl_exec($ch);
|
||
$err = curl_error($ch);
|
||
$errno = curl_errno($ch);
|
||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||
// 不调用 curl_close():PHP 8.0 起句柄由 GC 回收;8.5 起该函数被标记 deprecated,
|
||
// 调用会在输出里插入警告,污染本模块返回给前端的 JSON。
|
||
|
||
if ($out === false) {
|
||
return [
|
||
'ok' => false, 'status' => 0, 'headers' => [],
|
||
'body' => '',
|
||
'error' => $errno === CURLE_OPERATION_TIMEDOUT
|
||
? "请求超时({$timeout}s)"
|
||
: "网络错误({$errno}): {$err}",
|
||
];
|
||
}
|
||
|
||
return [
|
||
'ok' => $status >= 200 && $status < 300,
|
||
'status' => $status,
|
||
'headers' => $respHeaders,
|
||
'body' => $out,
|
||
'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}",
|
||
];
|
||
}
|
||
|
||
// 生成对象的访问 URL(路径部分逐段编码,保留斜杠)
|
||
function storageEncodeKey($key) {
|
||
$key = ltrim((string)$key, '/');
|
||
$segments = explode('/', $key);
|
||
return implode('/', array_map('rawurlencode', $segments));
|
||
}
|
||
|
||
// 解析 host 形式:bucket.endpoint 或自定义域名
|
||
function storageHostOf($domain, $useHttps = true) {
|
||
$domain = preg_replace('#^https?://#i', '', trim((string)$domain));
|
||
return ($useHttps ? 'https://' : 'http://') . rtrim($domain, '/');
|
||
}
|
||
|
||
// 统一把失败信息压缩成一行,便于写入设置页展示
|
||
function storageShortError($text, $max = 200) {
|
||
$text = preg_replace('/\s+/', ' ', trim((string)$text));
|
||
return mb_strlen($text, 'UTF-8') > $max ? mb_substr($text, 0, $max, 'UTF-8') . '…' : $text;
|
||
}
|