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(把每个修复改回旧写法,对应断言都会变红)。
125 lines
5.1 KiB
PHP
125 lines
5.1 KiB
PHP
<?php
|
||
/**
|
||
* 阿里云 ECS 实例元数据:RAM 角色临时凭据 + 地域。
|
||
* (从 MovieLog_Server 移植)
|
||
*
|
||
* 为什么值得支持这种方式:这样**服务器上不用落任何长期密钥**。
|
||
* 实例绑一个 RAM 角色,程序现用现取 STS 临时凭据;即使站点被拖库,
|
||
* 拿到的也只是几十分钟就过期的临时 token。
|
||
*
|
||
* ⚠️ 一台 ECS 只能绑一个 RAM 角色;该角色必须被授权访问目标 Bucket,
|
||
* 否则报 AccessDenied(NoPermissionType=ImplicitDeny)。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Storage;
|
||
|
||
use App\Setting;
|
||
|
||
final class EcsMeta
|
||
{
|
||
private const BASE = 'http://100.100.100.200/latest/meta-data/';
|
||
private const TIMEOUT = 3;
|
||
|
||
/**
|
||
* 取 RAM 角色的 STS 临时凭据。
|
||
* $roleName 留空则自动取实例绑定的第一个角色。
|
||
*/
|
||
public static function ramCredentials(int $familyId, string $roleName = '', bool $force = false): array
|
||
{
|
||
static $memo = [];
|
||
$memoKey = 'aliyun:' . $roleName;
|
||
if (!$force && isset($memo[$memoKey])) {
|
||
return $memo[$memoKey];
|
||
}
|
||
|
||
// 跨请求缓存:STS 一般 >= 1 小时,到期前 5 分钟就刷新
|
||
if (!$force) {
|
||
$cached = json_decode((string)Setting::get($familyId, 'cache_sts_aliyun', ''), true);
|
||
if (is_array($cached)
|
||
&& !empty($cached['expires']) && $cached['expires'] > time() + 300
|
||
&& ($cached['role'] ?? '') === $roleName) {
|
||
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
|
||
}
|
||
}
|
||
|
||
// IMDSv2:先拿一次性 token(拿不到就降级走 v1,兼容老实例)
|
||
$token = '';
|
||
$t = Http::send('PUT', self::BASE . '../api/token',
|
||
['X-aliyun-ecs-metadata-token-ttl-seconds: 21600'], null, self::TIMEOUT);
|
||
if ($t['ok'] && trim((string)$t['body']) !== '') {
|
||
$token = trim($t['body']);
|
||
}
|
||
$auth = $token !== '' ? ["X-aliyun-ecs-metadata-token: {$token}"] : [];
|
||
|
||
$credsUrl = self::BASE . 'ram/security-credentials/';
|
||
if ($roleName === '') {
|
||
$r = Http::send('GET', $credsUrl, $auth, null, self::TIMEOUT);
|
||
if (!$r['ok']) {
|
||
return $memo[$memoKey] = self::fail('无法访问 ECS 元数据服务(这台服务器可能不是阿里云 ECS)');
|
||
}
|
||
$first = trim((string)strtok((string)$r['body'], "\r\n"));
|
||
if ($first === '') {
|
||
return $memo[$memoKey] = self::fail('该实例没有绑定 RAM 角色');
|
||
}
|
||
$roleName = $first;
|
||
}
|
||
|
||
$r = Http::send('GET', $credsUrl . rawurlencode($roleName), $auth, null, self::TIMEOUT);
|
||
if (!$r['ok']) {
|
||
return $memo[$memoKey] = self::fail("取 RAM 角色 {$roleName} 的临时凭据失败");
|
||
}
|
||
$json = json_decode((string)$r['body'], true);
|
||
if (!is_array($json) || empty($json['AccessKeyId']) || empty($json['AccessKeySecret'])) {
|
||
$code = is_array($json) ? (string)($json['Code'] ?? '') : '';
|
||
return $memo[$memoKey] = self::fail('元数据返回的内容看不懂' . ($code !== '' ? "({$code})" : ''));
|
||
}
|
||
|
||
$cred = [
|
||
'ok' => true,
|
||
'id' => (string)$json['AccessKeyId'],
|
||
'secret' => (string)$json['AccessKeySecret'],
|
||
'token' => (string)($json['SecurityToken'] ?? ''),
|
||
'expires' => !empty($json['Expiration']) ? (int)strtotime((string)$json['Expiration']) : time() + 1800,
|
||
'error' => '',
|
||
'role' => $roleName,
|
||
];
|
||
Setting::set($familyId, 'cache_sts_aliyun', json_encode($cred, JSON_UNESCAPED_UNICODE));
|
||
return $memo[$memoKey] = $cred;
|
||
}
|
||
|
||
/** 本机地域,用于判断内网 endpoint 是否可用(1 小时缓存) */
|
||
public static function region(int $familyId, bool $force = false): string
|
||
{
|
||
static $memo = null;
|
||
if ($memo !== null && !$force) {
|
||
return $memo;
|
||
}
|
||
if (!$force) {
|
||
$c = json_decode((string)Setting::get($familyId, 'cache_ecs_region', ''), true);
|
||
if (is_array($c) && !empty($c['expires']) && $c['expires'] > time()) {
|
||
return $memo = (string)($c['region'] ?? '');
|
||
}
|
||
}
|
||
$r = Http::send('GET', self::BASE . 'region-id', [], null, self::TIMEOUT);
|
||
$region = $r['ok'] ? trim((string)$r['body']) : '';
|
||
Setting::set($familyId, 'cache_ecs_region',
|
||
json_encode(['region' => $region, 'expires' => time() + 3600]));
|
||
return $memo = $region;
|
||
}
|
||
|
||
/** 清掉全部元数据缓存(换角色/改配置后调用) */
|
||
public static function clearCache(int $familyId): void
|
||
{
|
||
foreach (['cache_sts_aliyun', 'cache_ecs_region', 'cache_oss_internal'] as $k) {
|
||
Setting::set($familyId, $k, '');
|
||
}
|
||
}
|
||
|
||
private static function fail(string $msg): array
|
||
{
|
||
return ['ok' => false, 'id' => '', 'secret' => '', 'token' => '',
|
||
'expires' => 0, 'error' => $msg, 'role' => ''];
|
||
}
|
||
} |