: * * 鉴权两种: * 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 */ 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), ]; } }