Files
FamilyBook/app/Storage/OssDriver.php
T
laoyang 7e9b4520c0 登录居中 · 个人 slogan · 三种生日 · 称呼视角化 · 附件统一走代理
按九点反馈一次性改造:

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 条(生日整框可点、登录居中)。
2026-09-30 09:44:55 +08:00

478 lines
19 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
/**
* 阿里云 OSS 驱动(手写签名 V1 / HMAC-SHA1,不依赖官方 SDK)。
* (从 MovieLog_Server 移植)
*
* ⚠️ 三件事必须记住:
* ① 写请求把 Content-Type 放进签名串,**读请求要传空串** —— 这是 OSS 签名的坑。
* ② 内网 endpoint 只用于**服务端读取**,对外 URL 永远用公网/自定义域名。
* 跨地域时内网域名能解析出 A 记录但永远连不上,会白等满一次连接超时,
* 所以先比地域(零成本),再只给 2 秒连接超时。
* ③ endpoint 填写时**不要带 https://**,且必须与桶所在地域一致,
* 否则报 "must be addressed using the specified endpoint"。
*/
declare(strict_types=1);
namespace App\Storage;
use App\Setting;
final class OssDriver implements StorageDriver
{
private array $cfg;
private int $familyId;
private ?array $cred = null;
public function __construct(int $familyId, array $cfg)
{
$this->familyId = $familyId;
$this->cfg = $cfg;
}
public function id(): string
{
return 'oss';
}
public function validateConfig(): string
{
if (trim((string)$this->cfg['bucket']) === '') {
return '没有填 Bucket 名称';
}
if (trim((string)$this->cfg['endpoint']) === '') {
return '没有填 Endpoint';
}
$auth = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
|| (trim((string)($this->cfg['access_key_id'] ?? '')) !== ''
&& trim((string)($this->cfg['access_key_secret'] ?? '')) !== '');
if (!$auth) {
return '凭据不完整:要么填 AccessKey,要么选「用服务器角色」';
}
return '';
}
// ---------------- 凭据 ----------------
private function credentials(): array
{
if ($this->cred !== null) {
return $this->cred;
}
if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') {
$c = EcsMeta::ramCredentials($this->familyId, (string)($this->cfg['ram_role_name'] ?? ''));
if (empty($c['ok'])) {
return $this->cred = ['ok' => false, 'error' => $c['error'] ?? '取临时凭据失败'];
}
return $this->cred = $c;
}
$id = trim((string)($this->cfg['access_key_id'] ?? ''));
$secret = trim((string)($this->cfg['access_key_secret'] ?? ''));
if ($id === '' || $secret === '') {
return $this->cred = ['ok' => false, 'error' => '没有填 AccessKey ID / AccessKey Secret'];
}
return $this->cred = ['ok' => true, 'id' => $id, 'secret' => $secret, 'token' => '', 'error' => ''];
}
// ---------------- endpoint / 域名 ----------------
private function useHttps(): bool
{
return (string)($this->cfg['https'] ?? '1') !== '0';
}
private function scheme(): string
{
return $this->useHttps() ? 'https' : 'http';
}
private function endpoint(): string
{
return trim((string)$this->cfg['endpoint']);
}
private function host(): string
{
return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint();
}
/** 内网 endpoint 只从标准域名推导,自定义/VPC 一律不猜 */
private function internalEndpoint(): string
{
if ((string)($this->cfg['internal_endpoint'] ?? '1') === '0') {
return '';
}
$ep = $this->endpoint();
if ($ep === '' || strpos($ep, '-internal.') !== false) {
return '';
}
if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) {
return $m[1] . '-internal.aliyuncs.com';
}
return '';
}
/** 内网是否可用:先比地域(零成本),必要时才真探一次 */
private function internalUsable(): bool
{
$internal = $this->internalEndpoint();
if ($internal === '') {
return false;
}
$cached = json_decode((string)Setting::get($this->familyId, 'cache_oss_internal', ''), true);
if (is_array($cached) && ($cached['endpoint'] ?? '') === $internal
&& !empty($cached['expires']) && $cached['expires'] > time()) {
return !empty($cached['ok']);
}
return $this->probeInternal($internal);
}
private function probeInternal(string $internal): bool
{
$ok = false;
$reason = '';
$bucketRegion = $this->bucketRegion();
$ecsRegion = EcsMeta::region($this->familyId);
if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) {
$reason = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域";
} else {
$cred = $this->credentials();
if (!empty($cred['ok'])) {
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
// HEAD 探测:即使没 ListBucket 权限回 403,也证明网络可达
$res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 3, 2);
$ok = $res['status'] > 0;
$reason = $ok ? '' : ('连接失败:' . (string)($res['error'] ?? ''));
} else {
$reason = '凭据不可用';
}
}
Setting::set($this->familyId, 'cache_oss_internal', json_encode([
'endpoint' => $internal,
'ok' => $ok ? 1 : 0,
'reason' => $reason,
'expires' => time() + ($ok ? 3600 : 1800),
], JSON_UNESCAPED_UNICODE));
return $ok;
}
/** 桶所在地域(用 GetBucketLocation,失败则从 endpoint 推) */
private function bucketRegion(): string
{
if (preg_match('#^oss-([a-z0-9-]+)\.aliyuncs\.com$#i', $this->endpoint(), $m)) {
return $m[1];
}
return '';
}
// ---------------- 签名 ----------------
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string
{
$canonicalHeaders = '';
if ((string)($cred['token'] ?? '') !== '') {
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
}
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/');
$stringToSign = $method . "\n"
. "\n" // Content-MD5(未使用)
. $contentType . "\n"
. $date . "\n"
. $canonicalHeaders
. $canonicalResource;
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
return 'OSS ' . $cred['id'] . ':' . $signature;
}
private function writeHeaders(string $method, string $key, string $contentType, array $cred): array
{
$date = gmdate('D, d M Y H:i:s \G\M\T');
$h = [
'Date: ' . $date,
'Content-Type: ' . $contentType,
'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred),
];
if ((string)($cred['token'] ?? '') !== '') {
$h[] = 'x-oss-security-token: ' . $cred['token'];
}
return $h;
}
/** 读请求刻意不发 Content-Type,签名串里传空串 */
private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array
{
$date = gmdate('D, d M Y H:i:s \G\M\T');
$h = [
'Date: ' . $date,
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
];
if ((string)($cred['token'] ?? '') !== '') {
$h[] = 'x-oss-security-token: ' . $cred['token'];
}
foreach ($extra as $x) {
$h[] = $x;
}
return $h;
}
// ---------------- 对象操作 ----------------
private function objectKey(string $key): string
{
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
$key = ltrim($key, '/');
return $prefix === '' ? $key : $prefix . '/' . $key;
}
private function objectUrl(string $key): string
{
return $this->scheme() . '://' . $this->host() . '/' . Http::encodeKey($key);
}
public function putFile(string $srcPath, string $key, ?string $mime = null): array
{
if (!is_file($srcPath)) {
return ['ok' => false, 'error' => '本地文件不存在'];
}
$binary = @file_get_contents($srcPath);
if ($binary === false) {
return ['ok' => false, 'error' => '读不出本地文件'];
}
return $this->putContents($binary, $key, $mime);
}
public function putContents(string $binary, string $key, ?string $mime = null): array
{
if ($err = $this->validateConfig()) {
return ['ok' => false, 'error' => $err];
}
$cred = $this->credentials();
if (empty($cred['ok'])) {
return ['ok' => false, 'error' => $cred['error']];
}
$key = $this->objectKey($key);
$mime = $mime ?: 'application/octet-stream';
$res = Http::send('PUT', $this->objectUrl($key),
$this->writeHeaders('PUT', $key, $mime, $cred), $binary, 60);
if (!$res['ok']) {
return ['ok' => false, 'error' => '上传到 OSS 失败:' . Http::shortError($res)];
}
return ['ok' => true, 'error' => ''];
}
public function delete(string $key): array
{
$cred = $this->credentials();
if (empty($cred['ok'])) {
return ['ok' => false, 'error' => $cred['error']];
}
$key = $this->objectKey($key);
$res = Http::send('DELETE', $this->objectUrl($key),
$this->writeHeaders('DELETE', $key, '', $cred), null, 30);
if (!$res['ok'] && (int)$res['status'] !== 404) {
return ['ok' => false, 'error' => '删除 OSS 对象失败:' . Http::shortError($res)];
}
return ['ok' => true, 'error' => ''];
}
/** 服务端读取(内网优先 + 自动回退外网) */
public function get(string $key, array $extra = []): array
{
if ($err = $this->validateConfig()) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
}
$cred = $this->credentials();
if (empty($cred['ok'])) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
}
$key = $this->objectKey($key);
$headers = $this->readHeaders($key, $cred, $extra);
$hosts = [];
$internal = $this->internalEndpoint();
if ($internal !== '' && $this->internalUsable()) {
$hosts[] = $internal;
}
$hosts[] = $this->endpoint();
$res = null;
foreach ($hosts as $i => $host) {
$isInternal = ($internal !== '' && $host === $internal);
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
. '/' . Http::encodeKey($key);
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
continue; // 内网连不上,换外网
}
break;
}
return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '连不上 OSS'];
}
public function exists(string $key): bool
{
$cred = $this->credentials();
if (empty($cred['ok'])) {
return false;
}
$key = $this->objectKey($key);
$res = Http::send('HEAD', $this->objectUrl($key), $this->readHeaders($key, $cred, [], 'HEAD'), null, 15);
return (int)$res['status'] === 200;
}
/** 对外地址:优先自定义域名/CDN */
public function url(string $key): string
{
$key = $this->objectKey($key);
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
if ($custom !== '') {
$host = Http::hostOf($custom);
return (strpos($custom, 'http') === 0 ? rtrim($custom, '/') : $this->scheme() . '://' . $host)
. '/' . Http::encodeKey($key);
}
return $this->objectUrl($key);
}
// ---------------- 连通性诊断 ----------------
/**
* 一步一步把「连不上 OSS」的原因定位到具体的哪一环。
*
* 为什么不能只用一个"测试存储是否可用"的按钮:
* 失败时它只会说"写入失败" —— 而失败可能是 6 种完全不同的原因
* (没填 bucket / 凭据取不到 / 桶地域与本机不同 / 内网域名解析不了 /
* AK 没这个桶的权限 / 桶 ACL 不是私有…),用户根本无从下手。
* 这里把每一步的**结论与耗时**都摊开,一眼能看出卡在哪。
*
* @return array<int, array{name:string,ok:bool,detail:string,ms:int}>
*/
public function diagnose(): array
{
$steps = [];
// ① 配置完整性(纯本地判断,最快,先挡掉一半问题)
$err = $this->validateConfig();
$steps[] = [
'name' => '配置完整性',
'ok' => $err === '',
'detail' => $err !== '' ? $err : sprintf('bucket=%s endpoint=%s 前缀=%s 凭据方式=%s',
$this->cfg['bucket'] ?: '(空)',
$this->endpoint() ?: '(空)',
trim((string)($this->cfg['prefix'] ?? '')) !== '' ? trim((string)$this->cfg['prefix']) : '(无)',
($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' ? '服务器绑定角色' : 'AccessKey'),
'ms' => 0,
];
if ($err !== '') {
return $steps; // 配置都没填全,后面每一步都会以同样的原因失败,不必刷屏
}
// ② 凭据
$t0 = microtime(true);
$cred = $this->credentials();
$ms = (int)round((microtime(true) - $t0) * 1000);
$steps[] = [
'name' => '取凭据',
'ok' => !empty($cred['ok']),
'detail' => !empty($cred['ok'])
? (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
? '已从实例 RAM 角色取到临时凭据' : '使用填写的 AccessKey')
: (string)($cred['error'] ?? '取不到凭据'),
'ms' => $ms,
];
if (empty($cred['ok'])) {
return $steps;
}
// ③ 地域比对 —— 跨地域时内网域名能解析但永远连不上,必须先说出来
$bucketRegion = $this->bucketRegion();
$ecsRegion = EcsMeta::region($this->familyId);
$sameRegion = ($bucketRegion === '' || $ecsRegion === '' || $bucketRegion === $ecsRegion);
$steps[] = [
'name' => '地域',
'ok' => $sameRegion,
'detail' => $bucketRegion === '' || $ecsRegion === ''
? sprintf('本机地域=%s 桶地域=%s(信息不全,跳过内网加速)',
$ecsRegion ?: '未知', $bucketRegion ?: '未知')
: ($sameRegion
? "本机与桶都在 {$bucketRegion},内网可达"
: "本机在 {$ecsRegion}、桶在 {$bucketRegion} —— **不是同一地域**,内网走不通,只能走公网"),
'ms' => 0,
];
// ④ 公网 endpoint 可达性(HEAD 桶,即使 403 也证明网络通了)
$url = $this->scheme() . '://' . $this->host() . '/';
$res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 10, 5);
$steps[] = [
'name' => '公网 endpoint 可达',
'ok' => (int)$res['status'] > 0,
'detail' => (int)$res['status'] > 0
? sprintf('%s → HTTP %d%s', $url, (int)$res['status'],
(int)$res['status'] === 403 ? '(可达;403 只是没有 ListBucket 权限,正常)' : '')
: (string)($res['error'] ?? '连不上'),
'ms' => (int)($res['ms'] ?? 0),
];
if ((int)$res['status'] === 0) {
return $steps; // 公网都不通,后面没意义
}
// ⑤ 内网 endpoint(只在同地域且配了内网时才测;跨地域测它是白等超时)
$internal = $this->internalEndpoint();
if ($internal !== '') {
if (!$sameRegion) {
$steps[] = ['name' => '内网 endpoint', 'ok' => true,
'detail' => '跳过:跨地域时内网域名能解析但连不上,测了只会白等一次超时', 'ms' => 0];
} else {
$iurl = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
$ires = Http::send('HEAD', $iurl, $this->readHeaders('/', $cred, [], 'HEAD'), null, 5, 2);
$steps[] = [
'name' => '内网 endpoint(服务端读取更快且免流量费)',
'ok' => (int)$ires['status'] > 0,
'detail' => (int)$ires['status'] > 0
? sprintf('%s → HTTP %d', $iurl, (int)$ires['status'])
: sprintf('%s → %s(会自动回退公网,功能不受影响)', $iurl, (string)($ires['error'] ?? '连不上')),
'ms' => (int)($ires['ms'] ?? 0),
];
}
}
// ⑥ 真写一个对象再读回来再删掉 —— 唯一能证明"有权限"的一步
$probeKey = 'avatars/.diag-' . bin2hex(random_bytes(6)) . '.txt';
$payload = 'familybook-diag-' . time();
$t0 = microtime(true);
$put = $this->putContents($payload, $probeKey, 'text/plain');
$putMs = (int)round((microtime(true) - $t0) * 1000);
if (empty($put['ok'])) {
$steps[] = ['name' => '写入(PUT)', 'ok' => false,
'detail' => (string)($put['error'] ?? '写入失败')
. ' —— 常见原因:这个 AK 对这个桶没有 PutObject 权限,或桶 ACL 不是私有/公共读写。',
'ms' => $putMs];
return $steps;
}
$steps[] = ['name' => '写入(PUT)', 'ok' => true, 'detail' => '对象已写入 ' . $probeKey, 'ms' => $putMs];
$get = $this->get($probeKey);
$same = !empty($get['ok']) && trim((string)$get['body']) === $payload;
$steps[] = [
'name' => '读回(GET)',
'ok' => $same,
'detail' => $same
? '读回内容与写入一致'
: sprintf('写入成功但读回来对不上(HTTP %d)—— 多半是内网/外网 endpoint 指错,或桶有版本/加密设置',
(int)($get['status'] ?? 0)),
'ms' => (int)($get['ms'] ?? 0),
];
$del = $this->delete($probeKey);
$steps[] = [
'name' => '删除(DELETE)',
'ok' => !empty($del['ok']),
'detail' => !empty($del['ok']) ? '探针对象已清理' : (string)($del['error'] ?? '删不掉(会留下垃圾文件)'),
'ms' => 0,
];
return $steps;
}
}