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