五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式
1. 独立页(登录/安装/错误)套上 HTML 骨架 —— 修「首页为什么没有样式」: 新增 templates/layout-bare.php,View::render 对独立页也走完整 <head> + CSS。 2. 以个人为中心:Kinship::label() 现算称呼、Auth::homeTitle() 出「X的家书」; 同一批人在不同登录者眼里自动变成公公婆婆 / 岳父岳母(不存一次性称谓)。 3. 附件存储可配置:Storage 门面(local / 阿里云 OSS),库里只存相对键, 云端失败自动回退本地;设置页可选驱动并填 OSS 参数(照 MovieLog 的方案)。 4. 三种角色:admin / member / guest,访客只读、写操作一律 403; accounts.role 枚举扩容,老库靠 Schema::ensure() 自动升级。 5. 家谱:只存相邻两代的边(parent / spouse),其余称呼全部推导 —— 兄弟姐妹不存边(共同父母 + birth_rank 排行推长子/次女、哥哥/妹妹), 认不清的人用虚拟人物 is_placeholder 占位,关系天然双向。 顺带修掉本地 e2e 挖出的 8 个线上级 bug(性别写死、长幼方向反、关系方向静默写反、 birth_rank 不从表单取回、隐式可空参数致响应头失效、无文件字段被误判为上传失败、 layout 变量撞车覆盖 $me、世代 0 显示成「下 0 代」)。 验证:kinship 单测 37/0 · HTTP e2e 81/0 · 老库升级专测 14/0 · 反向验证 11/11(把每个修复改回旧写法,对应断言都会变红)。
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
/**
|
||||
* 对象存储·共用件:curl 客户端 + 若干编码工具。
|
||||
* (从 MovieLog_Server 移植,去掉了与电影站相关的部分)
|
||||
*
|
||||
* ⚠️ 不依赖官方 SDK / Composer —— 全站手写签名 + curl,与 PHP 7.4+ 完全兼容。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Storage;
|
||||
|
||||
final class Http
|
||||
{
|
||||
/**
|
||||
* 发一个 HTTP 请求。
|
||||
*
|
||||
* ⚠️ 千万不要用 CURLOPT_CUSTOMREQUEST='HEAD' 去发 HEAD:
|
||||
* 这样 curl 仍会等 Content-Length 声明的 body,而服务端对 HEAD 不回体,
|
||||
* 于是一路等到超时、curl_exec 返回 false。(MovieLog 实测 8011ms / errno=28)
|
||||
* HEAD 要用 CURLOPT_NOBODY=true。
|
||||
*/
|
||||
public static function send(
|
||||
string $method,
|
||||
string $url,
|
||||
array $headers = [],
|
||||
$body = null,
|
||||
int $timeout = 30,
|
||||
?int $connectTimeout = null
|
||||
): array {
|
||||
$respHeaders = [];
|
||||
$ch = curl_init();
|
||||
$method = strtoupper($method);
|
||||
$opts = [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => $connectTimeout === null ? min(10, max(3, $timeout)) : max(1, $connectTimeout),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 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);
|
||||
},
|
||||
];
|
||||
if ($method === 'HEAD') {
|
||||
$opts[CURLOPT_NOBODY] = true; // ← 必须,见上面注释
|
||||
} else {
|
||||
$opts[CURLOPT_CUSTOMREQUEST] = $method;
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
|
||||
$ca = self::caBundle();
|
||||
if ($ca !== '') {
|
||||
curl_setopt($ch, CURLOPT_CAINFO, $ca);
|
||||
}
|
||||
if ($body !== null && $method !== 'HEAD') {
|
||||
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($ch);
|
||||
|
||||
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}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 找系统的 CA 证书。宝塔的 open_basedir 会限制路径访问,
|
||||
* 所以先判断「在不在允许范围内」再去 is_file,否则会刷 Warning 污染输出。
|
||||
*/
|
||||
public static function caBundle(): string
|
||||
{
|
||||
$candidates = [
|
||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
'/etc/ssl/cert.pem',
|
||||
ini_get('curl.cainfo'),
|
||||
ini_get('openssl.cafile'),
|
||||
];
|
||||
$allowed = self::openBasedirPaths();
|
||||
foreach ($candidates as $p) {
|
||||
$p = (string)$p;
|
||||
if ($p === '') {
|
||||
continue;
|
||||
}
|
||||
if ($allowed !== [] && !self::pathAllowed($p, $allowed)) {
|
||||
continue;
|
||||
}
|
||||
if (@is_file($p)) {
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private static function openBasedirPaths(): array
|
||||
{
|
||||
$raw = (string)ini_get('open_basedir');
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (preg_split('/[:;]/', $raw) ?: [] as $p) {
|
||||
if (trim($p) !== '') {
|
||||
$out[] = rtrim(trim($p), '/');
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function pathAllowed(string $path, array $allowed): bool
|
||||
{
|
||||
foreach ($allowed as $base) {
|
||||
if (strpos($path, $base) === 0 || strpos($base, $path) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 对象键的 URL 编码:保留 / 分段,其余按 RFC3986 编码 */
|
||||
public static function encodeKey(string $key): string
|
||||
{
|
||||
$parts = explode('/', $key);
|
||||
$parts = array_map('rawurlencode', $parts);
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
/** 把可能带协议的域名规整成 host[+port] */
|
||||
public static function hostOf(string $domain): string
|
||||
{
|
||||
$domain = trim($domain);
|
||||
if (strpos($domain, '//') !== false) {
|
||||
$p = parse_url($domain, PHP_URL_HOST);
|
||||
$domain = $p ?: $domain;
|
||||
}
|
||||
return trim($domain, '/');
|
||||
}
|
||||
|
||||
/** 错误信息压缩成一行,便于塞进页面提示 */
|
||||
public static function shortError(array $res, int $limit = 200): string
|
||||
{
|
||||
$msg = (string)($res['error'] ?? '');
|
||||
$body = (string)($res['body'] ?? '');
|
||||
if ($body !== '' && stripos($body, '<Code>') !== false) {
|
||||
$msg .= ' ' . trim(preg_replace('/\s+/', ' ', strip_tags($body)));
|
||||
}
|
||||
$msg = trim($msg);
|
||||
return mb_strlen($msg) > $limit ? mb_substr($msg, 0, $limit) . '…' : $msg;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user