按九点反馈一次性改造: 1. 登录页「图标 + 用手机号登录你的家书」包进 .auth-head 并真正居中 (以前只加了个 class 名,没有样式,看着像做了其实没做)。 2. slogan 改成两段式:系统级 slogan 作兜底,每个人可在自己的资料里写一个, 填了就显示自己的、留空就回落到系统那句。settings 里「家庭名称」字段去掉 (只在安装时归档,不参与展示)。 3. 三个生日:身份证生日(从证件号截取,永不改动)/真实阳历/农历。 同一人填了其一即自动推出另一个(靠 birthday_source 指明填的是哪一侧, 不依赖 JS)。三者落在同一天时合并成一条提醒,否则分别提醒。 农历表不是照抄流传的那份 lunarInfo(它在 5 个年份上是错的)—— 用五个独立实现按 7.3 万天多数表决重新推导,再要求 100% 重现。 4. 称呼改成「视角属性」:nicknames(family_id, viewer_id, target_id, nickname)。 「小明」不是某个人的属性——同一个人被不同人叫法不同,所以是「谁看」的属性。 旧的 persons.nickname 由 Nickname::migrateLegacy() 搬到新表,只搬一次。 5. 点自己的头像/名字直接进「我的资料」,不必再绕 「名册 → 找到自己 → 编辑资料」三步。 6. 附件(头像与其它上传)一律走 media.php 代理,不再直连 uploads/ 或云端地址: 可读性由登录态决定而不是「知道文件名」,Content-Type/缓存/下载头由服务端统一给。 uploads/ 整个目录 deny(含历史遗留备份文件),并有真实探针验证。 7. OSS 设置页加「连通性测试」(本地 / 云端两个按钮),逐步给出结论与耗时。 8. 生日输入改用「原生日期框 + 24 小时制时:分:秒 三个下拉」: datetime-local 的格式由浏览器 UI 语言决定(中文环境渲染成「下午 03:30」, 没有秒、还容易把上下午填反)。点输入框任意一处都能弹出选择器(showPicker)。 9. 结构版本号收敛成 Schema::VERSION 一处,安装时即盖章,避免首次请求白跑升级。 验证(全部为「先注入 bug 证伪、再跑通过」): php -l 42 个文件;e2e 149 条;老库升级专测 37 条;反向验证 23 个注入全被抓; 桌面几何 108 条(768/1280/1920/2560);移动端真机几何 212 条(360/375/390/414); 交互检查 49 条(生日整框可点、登录居中)。
178 lines
5.8 KiB
PHP
178 lines
5.8 KiB
PHP
<?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);
|
||
}
|
||
|
||
$t0 = microtime(true);
|
||
$out = curl_exec($ch);
|
||
$err = curl_error($ch);
|
||
$errno = curl_errno($ch);
|
||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||
curl_close($ch);
|
||
$ms = (int)round((microtime(true) - $t0) * 1000);
|
||
|
||
if ($out === false) {
|
||
return [
|
||
'ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'ms' => $ms,
|
||
'error' => $errno === CURLE_OPERATION_TIMEDOUT
|
||
? "请求超时({$timeout}s)"
|
||
: "网络错误({$errno}): {$err}",
|
||
];
|
||
}
|
||
return [
|
||
'ok' => $status >= 200 && $status < 300,
|
||
'status' => $status,
|
||
'headers' => $respHeaders,
|
||
'body' => $out,
|
||
'ms' => $ms,
|
||
'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;
|
||
}
|
||
} |