338 lines
12 KiB
PHP
338 lines
12 KiB
PHP
<?php
|
|||
|
|
/**
|
||
|
|
* 阿里云 OSS 驱动(手写签名 V1 / HMAC-SHA1,不依赖官方 SDK)。
|
||
|
|
* (从 MovieLog_Server 移植)
|
||
|
|
*
|
||
|
|
* ⚠️ 三件事必须记住:
|
||
|
|
* ① 写请求把 Content-Type 放进签名串,**读请求要传空串** —— 这是 OSS 签名的坑。
|
||
|
|
* ② 内网 endpoint 只用于**服务端读取**,对外 URL 永远用公网/自定义域名。
|
||
|
|
* 跨地域时内网域名能解析出 A 记录但永远连不上,会白等满一次连接超时,
|
||
|
|
* 所以先比地域(零成本),再只给 2 秒连接超时。
|
||
|
|
* ③ endpoint 填写时**不要带 https://**,且必须与桶所在地域一致,
|
||
|
|
* 否则报 "must be addressed using the specified endpoint"。
|
||
|
|
*/
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Storage;
|
||
|
|
|
||
|
|
use App\Setting;
|
||
|
|
|
||
|
|
final class OssDriver implements StorageDriver
|
||
|
|
{
|
||
|
|
private array $cfg;
|
||
|
|
private int $familyId;
|
||
|
|
private ?array $cred = null;
|
||
|
|
|
||
|
|
public function __construct(int $familyId, array $cfg)
|
||
|
|
{
|
||
|
|
$this->familyId = $familyId;
|
||
|
|
$this->cfg = $cfg;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function id(): string
|
||
|
|
{
|
||
|
|
return 'oss';
|
||
|
|
}
|
||
|
|
|
||
|
|
public function validateConfig(): string
|
||
|
|
{
|
||
|
|
if (trim((string)$this->cfg['bucket']) === '') {
|
||
|
|
return '没有填 Bucket 名称';
|
||
|
|
}
|
||
|
|
if (trim((string)$this->cfg['endpoint']) === '') {
|
||
|
|
return '没有填 Endpoint';
|
||
|
|
}
|
||
|
|
$auth = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
|
||
|
|
|| (trim((string)($this->cfg['access_key_id'] ?? '')) !== ''
|
||
|
|
&& trim((string)($this->cfg['access_key_secret'] ?? '')) !== '');
|
||
|
|
if (!$auth) {
|
||
|
|
return '凭据不完整:要么填 AccessKey,要么选「用服务器角色」';
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------- 凭据 ----------------
|
||
|
|
|
||
|
|
private function credentials(): array
|
||
|
|
{
|
||
|
|
if ($this->cred !== null) {
|
||
|
|
return $this->cred;
|
||
|
|
}
|
||
|
|
if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') {
|
||
|
|
$c = EcsMeta::ramCredentials($this->familyId, (string)($this->cfg['ram_role_name'] ?? ''));
|
||
|
|
if (empty($c['ok'])) {
|
||
|
|
return $this->cred = ['ok' => false, 'error' => $c['error'] ?? '取临时凭据失败'];
|
||
|
|
}
|
||
|
|
return $this->cred = $c;
|
||
|
|
}
|
||
|
|
$id = trim((string)($this->cfg['access_key_id'] ?? ''));
|
||
|
|
$secret = trim((string)($this->cfg['access_key_secret'] ?? ''));
|
||
|
|
if ($id === '' || $secret === '') {
|
||
|
|
return $this->cred = ['ok' => false, 'error' => '没有填 AccessKey ID / AccessKey Secret'];
|
||
|
|
}
|
||
|
|
return $this->cred = ['ok' => true, 'id' => $id, 'secret' => $secret, 'token' => '', 'error' => ''];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------- endpoint / 域名 ----------------
|
||
|
|
|
||
|
|
private function useHttps(): bool
|
||
|
|
{
|
||
|
|
return (string)($this->cfg['https'] ?? '1') !== '0';
|
||
|
|
}
|
||
|
|
|
||
|
|
private function scheme(): string
|
||
|
|
{
|
||
|
|
return $this->useHttps() ? 'https' : 'http';
|
||
|
|
}
|
||
|
|
|
||
|
|
private function endpoint(): string
|
||
|
|
{
|
||
|
|
return trim((string)$this->cfg['endpoint']);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function host(): string
|
||
|
|
{
|
||
|
|
return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 内网 endpoint 只从标准域名推导,自定义/VPC 一律不猜 */
|
||
|
|
private function internalEndpoint(): string
|
||
|
|
{
|
||
|
|
if ((string)($this->cfg['internal_endpoint'] ?? '1') === '0') {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$ep = $this->endpoint();
|
||
|
|
if ($ep === '' || strpos($ep, '-internal.') !== false) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) {
|
||
|
|
return $m[1] . '-internal.aliyuncs.com';
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 内网是否可用:先比地域(零成本),必要时才真探一次 */
|
||
|
|
private function internalUsable(): bool
|
||
|
|
{
|
||
|
|
$internal = $this->internalEndpoint();
|
||
|
|
if ($internal === '') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
$cached = json_decode((string)Setting::get($this->familyId, 'cache_oss_internal', ''), true);
|
||
|
|
if (is_array($cached) && ($cached['endpoint'] ?? '') === $internal
|
||
|
|
&& !empty($cached['expires']) && $cached['expires'] > time()) {
|
||
|
|
return !empty($cached['ok']);
|
||
|
|
}
|
||
|
|
return $this->probeInternal($internal);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function probeInternal(string $internal): bool
|
||
|
|
{
|
||
|
|
$ok = false;
|
||
|
|
$reason = '';
|
||
|
|
$bucketRegion = $this->bucketRegion();
|
||
|
|
$ecsRegion = EcsMeta::region($this->familyId);
|
||
|
|
|
||
|
|
if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) {
|
||
|
|
$reason = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域";
|
||
|
|
} else {
|
||
|
|
$cred = $this->credentials();
|
||
|
|
if (!empty($cred['ok'])) {
|
||
|
|
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
|
||
|
|
// HEAD 探测:即使没 ListBucket 权限回 403,也证明网络可达
|
||
|
|
$res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 3, 2);
|
||
|
|
$ok = $res['status'] > 0;
|
||
|
|
$reason = $ok ? '' : ('连接失败:' . (string)($res['error'] ?? ''));
|
||
|
|
} else {
|
||
|
|
$reason = '凭据不可用';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Setting::set($this->familyId, 'cache_oss_internal', json_encode([
|
||
|
|
'endpoint' => $internal,
|
||
|
|
'ok' => $ok ? 1 : 0,
|
||
|
|
'reason' => $reason,
|
||
|
|
'expires' => time() + ($ok ? 3600 : 1800),
|
||
|
|
], JSON_UNESCAPED_UNICODE));
|
||
|
|
return $ok;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 桶所在地域(用 GetBucketLocation,失败则从 endpoint 推) */
|
||
|
|
private function bucketRegion(): string
|
||
|
|
{
|
||
|
|
if (preg_match('#^oss-([a-z0-9-]+)\.aliyuncs\.com$#i', $this->endpoint(), $m)) {
|
||
|
|
return $m[1];
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------- 签名 ----------------
|
||
|
|
|
||
|
|
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string
|
||
|
|
{
|
||
|
|
$canonicalHeaders = '';
|
||
|
|
if ((string)($cred['token'] ?? '') !== '') {
|
||
|
|
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
|
||
|
|
}
|
||
|
|
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/');
|
||
|
|
$stringToSign = $method . "\n"
|
||
|
|
. "\n" // Content-MD5(未使用)
|
||
|
|
. $contentType . "\n"
|
||
|
|
. $date . "\n"
|
||
|
|
. $canonicalHeaders
|
||
|
|
. $canonicalResource;
|
||
|
|
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
|
||
|
|
return 'OSS ' . $cred['id'] . ':' . $signature;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function writeHeaders(string $method, string $key, string $contentType, array $cred): array
|
||
|
|
{
|
||
|
|
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||
|
|
$h = [
|
||
|
|
'Date: ' . $date,
|
||
|
|
'Content-Type: ' . $contentType,
|
||
|
|
'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred),
|
||
|
|
];
|
||
|
|
if ((string)($cred['token'] ?? '') !== '') {
|
||
|
|
$h[] = 'x-oss-security-token: ' . $cred['token'];
|
||
|
|
}
|
||
|
|
return $h;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 读请求刻意不发 Content-Type,签名串里传空串 */
|
||
|
|
private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array
|
||
|
|
{
|
||
|
|
$date = gmdate('D, d M Y H:i:s \G\M\T');
|
||
|
|
$h = [
|
||
|
|
'Date: ' . $date,
|
||
|
|
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
|
||
|
|
];
|
||
|
|
if ((string)($cred['token'] ?? '') !== '') {
|
||
|
|
$h[] = 'x-oss-security-token: ' . $cred['token'];
|
||
|
|
}
|
||
|
|
foreach ($extra as $x) {
|
||
|
|
$h[] = $x;
|
||
|
|
}
|
||
|
|
return $h;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------- 对象操作 ----------------
|
||
|
|
|
||
|
|
private function objectKey(string $key): string
|
||
|
|
{
|
||
|
|
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
||
|
|
$key = ltrim($key, '/');
|
||
|
|
return $prefix === '' ? $key : $prefix . '/' . $key;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function objectUrl(string $key): string
|
||
|
|
{
|
||
|
|
return $this->scheme() . '://' . $this->host() . '/' . Http::encodeKey($key);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function putFile(string $srcPath, string $key, ?string $mime = null): array
|
||
|
|
{
|
||
|
|
if (!is_file($srcPath)) {
|
||
|
|
return ['ok' => false, 'error' => '本地文件不存在'];
|
||
|
|
}
|
||
|
|
$binary = @file_get_contents($srcPath);
|
||
|
|
if ($binary === false) {
|
||
|
|
return ['ok' => false, 'error' => '读不出本地文件'];
|
||
|
|
}
|
||
|
|
return $this->putContents($binary, $key, $mime);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function putContents(string $binary, string $key, ?string $mime = null): array
|
||
|
|
{
|
||
|
|
if ($err = $this->validateConfig()) {
|
||
|
|
return ['ok' => false, 'error' => $err];
|
||
|
|
}
|
||
|
|
$cred = $this->credentials();
|
||
|
|
if (empty($cred['ok'])) {
|
||
|
|
return ['ok' => false, 'error' => $cred['error']];
|
||
|
|
}
|
||
|
|
$key = $this->objectKey($key);
|
||
|
|
$mime = $mime ?: 'application/octet-stream';
|
||
|
|
$res = Http::send('PUT', $this->objectUrl($key),
|
||
|
|
$this->writeHeaders('PUT', $key, $mime, $cred), $binary, 60);
|
||
|
|
|
||
|
|
if (!$res['ok']) {
|
||
|
|
return ['ok' => false, 'error' => '上传到 OSS 失败:' . Http::shortError($res)];
|
||
|
|
}
|
||
|
|
return ['ok' => true, 'error' => ''];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function delete(string $key): array
|
||
|
|
{
|
||
|
|
$cred = $this->credentials();
|
||
|
|
if (empty($cred['ok'])) {
|
||
|
|
return ['ok' => false, 'error' => $cred['error']];
|
||
|
|
}
|
||
|
|
$key = $this->objectKey($key);
|
||
|
|
$res = Http::send('DELETE', $this->objectUrl($key),
|
||
|
|
$this->writeHeaders('DELETE', $key, '', $cred), null, 30);
|
||
|
|
|
||
|
|
if (!$res['ok'] && (int)$res['status'] !== 404) {
|
||
|
|
return ['ok' => false, 'error' => '删除 OSS 对象失败:' . Http::shortError($res)];
|
||
|
|
}
|
||
|
|
return ['ok' => true, 'error' => ''];
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 服务端读取(内网优先 + 自动回退外网) */
|
||
|
|
public function get(string $key, array $extra = []): array
|
||
|
|
{
|
||
|
|
if ($err = $this->validateConfig()) {
|
||
|
|
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
|
||
|
|
}
|
||
|
|
$cred = $this->credentials();
|
||
|
|
if (empty($cred['ok'])) {
|
||
|
|
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
|
||
|
|
}
|
||
|
|
$key = $this->objectKey($key);
|
||
|
|
$headers = $this->readHeaders($key, $cred, $extra);
|
||
|
|
|
||
|
|
$hosts = [];
|
||
|
|
$internal = $this->internalEndpoint();
|
||
|
|
if ($internal !== '' && $this->internalUsable()) {
|
||
|
|
$hosts[] = $internal;
|
||
|
|
}
|
||
|
|
$hosts[] = $this->endpoint();
|
||
|
|
|
||
|
|
$res = null;
|
||
|
|
foreach ($hosts as $i => $host) {
|
||
|
|
$isInternal = ($internal !== '' && $host === $internal);
|
||
|
|
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
|
||
|
|
. '/' . Http::encodeKey($key);
|
||
|
|
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
|
||
|
|
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
|
||
|
|
continue; // 内网连不上,换外网
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '连不上 OSS'];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function exists(string $key): bool
|
||
|
|
{
|
||
|
|
$cred = $this->credentials();
|
||
|
|
if (empty($cred['ok'])) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
$key = $this->objectKey($key);
|
||
|
|
$res = Http::send('HEAD', $this->objectUrl($key), $this->readHeaders($key, $cred, [], 'HEAD'), null, 15);
|
||
|
|
return (int)$res['status'] === 200;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 对外地址:优先自定义域名/CDN */
|
||
|
|
public function url(string $key): string
|
||
|
|
{
|
||
|
|
$key = $this->objectKey($key);
|
||
|
|
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
|
||
|
|
if ($custom !== '') {
|
||
|
|
$host = Http::hostOf($custom);
|
||
|
|
return (strpos($custom, 'http') === 0 ? rtrim($custom, '/') : $this->scheme() . '://' . $host)
|
||
|
|
. '/' . Http::encodeKey($key);
|
||
|
|
}
|
||
|
|
return $this->objectUrl($key);
|
||
|
|
}
|
||
|
|
}
|