Files
FamilyBook/app/Storage/Http.php
T
laoyang 1669ab48d0 五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式
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(把每个修复改回旧写法,对应断言都会变红)。
2026-09-30 08:13:07 +08:00

175 lines
5.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}