Files
TripLog/src/Oss.php
T
laoyang f9a624ef4b 交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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 字节)
2026-09-22 23:09:37 +08:00

397 lines
15 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
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),
];
}
}