交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - trip_nodes 的历史列保留但不再写入也不再渲染,编辑一次即被显式清成 NULL - 节点表单按出行方式切换号码标签(航班号 / 车次 / 班次 / 线路 / 航次) 照片 - 一个节点可传多张照片,前端支持逐张裁剪(Cropper.js,只把裁过的走 base64) - MIME 由自建的 src/Mime.php 按内容识别:getimagesize → 魔数嗅探 → 扩展名映射, 刻意不依赖 fileinfo —— 线上同时装着 PHP 7.4(没装该扩展)与 8.1 - 单张体积上限、可传张数在设置页可调 双存储后端 - 本站 uploads/ 或阿里云 OSS,在「存储设置」页配 endpoint / 桶名 / 前缀 / 内网开关; OSS 配置不完整时静默回退本地,避免管理员填错就把上传整个弄挂 - OSS 鉴权支持 ECS 实例 RAM 角色换 STS 临时凭证(临时凭证缓存在 /tmp,不落 Web 目录), 也支持直接填 AccessKey - OSS V1 签名手写实现,不引 SDK(本项目无 composer / 无 autoload) 媒体代理 - 照片与活动封面一律经 media/show、media/cover 代理输出,先校验家庭归属再吐字节 - 页面里不出现 OSS 域名,也不出现本地真实路径;桶保持私有 - 响应带 nosniff,并关闭 display_errors,免得 PHP 报错混进图片正文把图弄坏 数据库 - 新增迁移机制(src/Migrate.php + tools/migrate.php,仅 CLI 可跑) - 建 app_settings 表;attachments 增加 storage / object_key / mime_type / size_bytes / width / height / is_image 与 idx_storage - MySQL 8 不支持「加列时带 IF NOT EXISTS」,迁移一律先查 information_schema 界面 - 顶栏只留品牌与「新建活动」,其余入口收进右上角下拉菜单(details/summary,零 JS 可开合) - 新增「存储设置」页:当前生效存储、数据库结构版本、是否 ECS 运行环境三张状态卡, 外加测试连接与角色探测 - 样式表整体重写 验证 - 模板渲染检查 234 项、SQL 列名静态检查、模板到 CSS 的类覆盖检查、内联 JS 语法检查全通过 - 已部署到 trip.yangyq.net 并完成线上验证:双 PHP(8.1 / 7.4)14 条路由渲染字节数完全一致、 26 项内容断言全部通过、照片代理取件与磁盘原件逐字节一致(69/69 字节)
397 lines
15 KiB
PHP
397 lines
15 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
/**
|
||
* 极简阿里云 OSS 客户端(不依赖 composer,只有 curl + hash)。
|
||
*
|
||
* 为什么手写:项目没有 composer / autoload,官方 SDK 拉不进来。
|
||
* OSS 的 V1 签名本身很简单,够用且可审计:
|
||
*
|
||
* StringToSign = VERB \n Content-MD5 \n Content-Type \n Date \n
|
||
* CanonicalizedOSSHeaders + CanonicalizedResource
|
||
* Authorization: OSS <AccessKeyId>:<base64(hmac-sha1(AccessKeySecret, StringToSign))>
|
||
*
|
||
* 鉴权两种:
|
||
* 1) ecs_role —— 从 ECS 实例元数据取 RAM 角色的 STS 临时凭证(含 SecurityToken,
|
||
* 需要作为 x-oss-security-token 头,并计入 CanonicalizedOSSHeaders);
|
||
* 2) access_key —— 直接用长期 AK/SK。
|
||
*
|
||
* 桶是**私有**的:本类从不生成对外直链,取文件一律经 PHP 代理(见 MediaController)。
|
||
*/
|
||
final class Oss
|
||
{
|
||
private const META_BASE = 'http://100.100.100.200/latest/meta-data/ram/security-credentials/';
|
||
|
||
private string $endpoint; // 已归一化(去掉 scheme、去尾斜杠)
|
||
private string $bucket;
|
||
private string $prefix;
|
||
private string $auth; // ecs_role | access_key
|
||
private string $roleName;
|
||
private string $akId;
|
||
private string $akSecret;
|
||
|
||
/** @var array{id:string,secret:string,token:string,expire:int} 运行时凭证 */
|
||
private array $cred = ['id' => '', 'secret' => '', 'token' => '', 'expire' => 0];
|
||
|
||
public function __construct(array $cfg)
|
||
{
|
||
$this->bucket = trim((string)($cfg['bucket'] ?? ''));
|
||
$this->prefix = self::normalizePrefix((string)($cfg['prefix'] ?? ''));
|
||
$this->auth = (string)($cfg['auth'] ?? 'ecs_role');
|
||
$this->roleName = (string)($cfg['role_name'] ?? '');
|
||
$this->akId = (string)($cfg['ak_id'] ?? '');
|
||
$this->akSecret = (string)($cfg['ak_secret'] ?? '');
|
||
|
||
$endpoint = trim((string)($cfg['endpoint'] ?? ''));
|
||
$endpoint = preg_replace('#^https?://#i', '', $endpoint) ?? $endpoint;
|
||
$endpoint = rtrim($endpoint, '/');
|
||
// 「内网」不是单独的参数,而是在 endpoint 里插 -internal:
|
||
// oss-cn-qingdao.aliyuncs.com -> oss-cn-qingdao-internal.aliyuncs.com
|
||
if (!empty($cfg['internal']) && strpos($endpoint, '-internal.') === false) {
|
||
$endpoint = preg_replace('/^([a-z0-9-]+?)(\.[a-z0-9-]+\.[a-z]+)$/i', '$1-internal$2', $endpoint, 1) ?? $endpoint;
|
||
}
|
||
$this->endpoint = $endpoint;
|
||
}
|
||
|
||
public function bucket(): string { return $this->bucket; }
|
||
public function prefix(): string { return $this->prefix; }
|
||
public function endpoint(): string { return $this->endpoint; }
|
||
|
||
/** 元数据/设置里的相对键 → 带前缀的完整对象键 */
|
||
public function objectKey(string $key): string
|
||
{
|
||
return $this->prefix . ltrim($key, '/');
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────── 基本操作
|
||
|
||
/** 上传(覆盖)。$key 为不含前缀的相对键 */
|
||
public function put(string $key, string $body, string $mime = 'application/octet-stream'): void
|
||
{
|
||
$res = $this->request('PUT', $this->objectKey($key), [
|
||
'Content-Type' => $mime,
|
||
'Content-MD5' => base64_encode(md5($body, true)),
|
||
], $body);
|
||
if ($res['status'] < 200 || $res['status'] >= 300) {
|
||
throw new \RuntimeException('OSS 上传失败(HTTP ' . $res['status'] . '):' . self::short($res['body']));
|
||
}
|
||
}
|
||
|
||
/** 下载对象内容 */
|
||
public function get(string $key): string
|
||
{
|
||
$res = $this->request('GET', $this->objectKey($key));
|
||
if ($res['status'] === 404) {
|
||
throw new \RuntimeException('对象不存在:' . $key);
|
||
}
|
||
if ($res['status'] < 200 || $res['status'] >= 300) {
|
||
throw new \RuntimeException('OSS 读取失败(HTTP ' . $res['status'] . '):' . self::short($res['body']));
|
||
}
|
||
return $res['body'];
|
||
}
|
||
|
||
/** 读取对象响应头(用于代理时取 Content-Type / Content-Length 而不下载正文) */
|
||
public function head(string $key): ?array
|
||
{
|
||
$res = $this->request('HEAD', $this->objectKey($key));
|
||
if ($res['status'] < 200 || $res['status'] >= 300) {
|
||
return null;
|
||
}
|
||
return self::parseHeaders($res['headers']);
|
||
}
|
||
|
||
public function delete(string $key): bool
|
||
{
|
||
$res = $this->request('DELETE', $this->objectKey($key));
|
||
// 404 视为已不存在,同样算成功(幂等)
|
||
return ($res['status'] >= 200 && $res['status'] < 300) || $res['status'] === 404;
|
||
}
|
||
|
||
/**
|
||
* 走一遍「上传 → 读取 → 删除」,验证凭证对目标桶真的有读写权限。
|
||
* 比只做 HEAD 桶更贴近真实用途(很多 RAM 策略不给 HeadBucket)。
|
||
*
|
||
* @return array{ok:bool,message:string,steps:string[]}
|
||
*/
|
||
public function testRoundTrip(): array
|
||
{
|
||
$key = '.healthcheck/' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.txt';
|
||
$body = 'triplog healthcheck ' . date('c');
|
||
$steps = [];
|
||
|
||
try {
|
||
$this->put($key, $body, 'text/plain');
|
||
$steps[] = '上传 OK';
|
||
} catch (\Throwable $e) {
|
||
return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps];
|
||
}
|
||
|
||
try {
|
||
$got = $this->get($key);
|
||
if ($got !== $body) {
|
||
return ['ok' => false, 'message' => '读取内容与上传不一致', 'steps' => array_merge($steps, ['读取 内容不符'])];
|
||
}
|
||
$steps[] = '读取 OK';
|
||
} catch (\Throwable $e) {
|
||
$steps[] = '读取 失败';
|
||
$this->safeDelete($key);
|
||
return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps];
|
||
}
|
||
|
||
$ok = $this->safeDelete($key);
|
||
$steps[] = $ok ? '删除 OK' : '删除 失败';
|
||
|
||
return [
|
||
'ok' => $ok,
|
||
'message' => $ok ? '连接正常,读写与删除都可用' : '上传读取正常,但删除失败(检查 RAM 策略是否含 DeleteObject)',
|
||
'steps' => $steps,
|
||
];
|
||
}
|
||
|
||
private function safeDelete(string $key): bool
|
||
{
|
||
try {
|
||
return $this->delete($key);
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────── ECS 元数据
|
||
|
||
/**
|
||
* 列出该 ECS 实例可用的 RAM 角色名。
|
||
* 结果在单次请求内缓存 —— 不在 ECS 上时这里要等一次超时,别重复问。
|
||
*
|
||
* @return string[]
|
||
*/
|
||
public static function listEcsRoles(): array
|
||
{
|
||
static $cache = null;
|
||
if ($cache !== null) {
|
||
return $cache;
|
||
}
|
||
|
||
$res = self::httpGet(self::META_BASE, 2);
|
||
if ($res === null) {
|
||
return $cache = [];
|
||
}
|
||
$roles = array_filter(array_map('trim', explode("\n", $res)));
|
||
return $cache = array_values(array_unique(array_filter($roles, static function (string $r): bool {
|
||
return $r !== '' && strpos($r, '404') === false;
|
||
})));
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────── 内部实现
|
||
|
||
private function request(string $verb, string $resourceKey, array $headers = [], string $body = ''): array
|
||
{
|
||
$cred = $this->credential();
|
||
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||
|
||
// x-oss-* 头(含 STS 的 security-token)必须参与签名
|
||
$ossHeaders = [];
|
||
if ($cred['token'] !== '') {
|
||
$ossHeaders['x-oss-security-token'] = $cred['token'];
|
||
}
|
||
|
||
$canonicalizedOssHeaders = '';
|
||
ksort($ossHeaders);
|
||
foreach ($ossHeaders as $k => $v) {
|
||
$canonicalizedOssHeaders .= strtolower($k) . ':' . $v . "\n";
|
||
}
|
||
|
||
$canonicalizedResource = '/' . $this->bucket . '/' . $resourceKey;
|
||
|
||
$stringToSign = $verb . "\n"
|
||
. ($headers['Content-MD5'] ?? '') . "\n"
|
||
. ($headers['Content-Type'] ?? '') . "\n"
|
||
. $date . "\n"
|
||
. $canonicalizedOssHeaders
|
||
. $canonicalizedResource;
|
||
|
||
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
|
||
|
||
$reqHeaders = array_merge($headers, $ossHeaders, [
|
||
'Date' => $date,
|
||
'Authorization' => 'OSS ' . $cred['id'] . ':' . $signature,
|
||
]);
|
||
|
||
$url = 'https://' . $this->bucket . '.' . $this->endpoint . '/' . self::encodeKey($resourceKey);
|
||
|
||
return self::httpRequest($verb, $url, $reqHeaders, $body, 30);
|
||
}
|
||
|
||
/**
|
||
* 取到可用凭证:ECS 角色走元数据(带文件缓存,避免每个请求都去问元数据服务),
|
||
* AK/SK 直接返回;两者统一成 {id, secret, token, expire}。
|
||
*/
|
||
private function credential(): array
|
||
{
|
||
if ($this->cred['id'] !== '' && $this->cred['expire'] > time()) {
|
||
return $this->cred;
|
||
}
|
||
|
||
if ($this->auth === 'access_key') {
|
||
if ($this->akId === '' || $this->akSecret === '') {
|
||
throw new \RuntimeException('未填写 AccessKeyId / AccessKeySecret');
|
||
}
|
||
return $this->cred = ['id' => $this->akId, 'secret' => $this->akSecret, 'token' => '', 'expire' => time() + 3600];
|
||
}
|
||
|
||
if ($this->roleName === '') {
|
||
throw new \RuntimeException('未选择 ECS 角色名');
|
||
}
|
||
|
||
// 先看缓存
|
||
$cached = $this->readCredCache();
|
||
if ($cached !== null) {
|
||
return $this->cred = $cached;
|
||
}
|
||
|
||
$url = self::META_BASE . rawurlencode($this->roleName);
|
||
$json = self::httpGet($url, 3);
|
||
if ($json === null) {
|
||
throw new \RuntimeException('读取 ECS 元数据失败(该机器可能不是 ECS,或元数据服务不可达)');
|
||
}
|
||
$data = json_decode($json, true);
|
||
if (!is_array($data) || empty($data['AccessKeyId']) || empty($data['AccessKeySecret'])) {
|
||
throw new \RuntimeException('ECS 元数据未返回有效凭证:' . self::short($json));
|
||
}
|
||
if (($data['Code'] ?? 'Success') !== 'Success') {
|
||
throw new \RuntimeException('ECS 角色凭证获取失败:' . (string)($data['Code'] ?? ''));
|
||
}
|
||
|
||
$expire = isset($data['Expiration']) ? (int)strtotime((string)$data['Expiration']) : (time() + 900);
|
||
$cred = [
|
||
'id' => (string)$data['AccessKeyId'],
|
||
'secret' => (string)$data['AccessKeySecret'],
|
||
'token' => (string)($data['SecurityToken'] ?? ''),
|
||
// 提前 5 分钟作废,避免签名时刚好过期
|
||
'expire' => max(time() + 60, $expire - 300),
|
||
];
|
||
$this->writeCredCache($cred);
|
||
return $this->cred = $cred;
|
||
}
|
||
|
||
/**
|
||
* 凭证缓存落在 /tmp(**不能在 web 目录下**:STS 临时凭证是密钥,
|
||
* 放 uploads/ 会被直接下载走)。open_basedir 允许 /tmp。
|
||
*/
|
||
private function cachePath(): string
|
||
{
|
||
$id = substr(hash('sha256', $this->endpoint . '|' . $this->bucket . '|' . $this->auth . '|' . $this->roleName), 0, 16);
|
||
return rtrim(sys_get_temp_dir(), '/\\') . '/triplog_oss_' . $id . '.json';
|
||
}
|
||
|
||
private function readCredCache(): ?array
|
||
{
|
||
$f = $this->cachePath();
|
||
if (!is_file($f)) return null;
|
||
$raw = @file_get_contents($f);
|
||
if ($raw === false || $raw === '') return null;
|
||
$d = json_decode($raw, true);
|
||
if (!is_array($d) || empty($d['id']) || empty($d['secret']) || (int)($d['expire'] ?? 0) <= time()) {
|
||
@unlink($f);
|
||
return null;
|
||
}
|
||
return ['id' => (string)$d['id'], 'secret' => (string)$d['secret'],
|
||
'token' => (string)($d['token'] ?? ''), 'expire' => (int)$d['expire']];
|
||
}
|
||
|
||
private function writeCredCache(array $cred): void
|
||
{
|
||
$f = $this->cachePath();
|
||
@file_put_contents($f, json_encode($cred));
|
||
@chmod($f, 0600);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────── 工具
|
||
|
||
/** 对象键编码:逐段 rawurlencode,保留 '/' */
|
||
private static function encodeKey(string $key): string
|
||
{
|
||
return implode('/', array_map('rawurlencode', explode('/', $key)));
|
||
}
|
||
|
||
private static function normalizePrefix(string $prefix): string
|
||
{
|
||
$prefix = trim($prefix);
|
||
if ($prefix === '') return '';
|
||
return rtrim($prefix, '/') . '/';
|
||
}
|
||
|
||
private static function short(string $s): string
|
||
{
|
||
$s = trim(preg_replace('/\s+/', ' ', $s) ?? '');
|
||
return mb_strlen($s) > 200 ? mb_substr($s, 0, 200) . '…' : $s;
|
||
}
|
||
|
||
/** @return array<string,string> */
|
||
private static function parseHeaders(array $rawHeaders): array
|
||
{
|
||
$out = [];
|
||
foreach ($rawHeaders as $line) {
|
||
$pos = strpos($line, ':');
|
||
if ($pos === false) continue;
|
||
$out[strtolower(trim(substr($line, 0, $pos)))] = trim(substr($line, $pos + 1));
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
private static function httpGet(string $url, int $timeout): ?string
|
||
{
|
||
$res = self::httpRequest('GET', $url, ['Date' => gmdate('D, d M Y H:i:s \G\M\T')], '', $timeout);
|
||
return ($res['status'] >= 200 && $res['status'] < 300) ? $res['body'] : null;
|
||
}
|
||
|
||
/**
|
||
* @return array{status:int, body:string, headers:string[]}
|
||
*/
|
||
private static function httpRequest(string $verb, string $url, array $headers, string $body, int $timeout): array
|
||
{
|
||
if (!function_exists('curl_init')) {
|
||
throw new \RuntimeException('缺少 curl 扩展,无法访问对象存储');
|
||
}
|
||
|
||
$ch = curl_init($url);
|
||
$headerLines = [];
|
||
foreach ($headers as $k => $v) {
|
||
$headerLines[] = $k . ': ' . $v;
|
||
}
|
||
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_CUSTOMREQUEST => $verb,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_HEADER => true,
|
||
CURLOPT_HTTPHEADER => $headerLines,
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||
CURLOPT_FOLLOWLOCATION => false,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
]);
|
||
if ($body !== '' || in_array($verb, ['PUT', 'POST'], true)) {
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||
}
|
||
|
||
$raw = curl_exec($ch);
|
||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$hsize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||
$err = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($raw === false) {
|
||
throw new \RuntimeException('网络请求失败:' . $err);
|
||
}
|
||
|
||
return [
|
||
'status' => $status,
|
||
'headers' => explode("\r\n", substr((string)$raw, 0, $hsize)),
|
||
'body' => substr((string)$raw, $hsize),
|
||
];
|
||
}
|
||
}
|