Files
lgp-admin-plus-api/app/Service/common/upload/S3CompatibleStorageService.php
LQ bcf54c2727 初始化
缺陷:主题配色需要优化整体的同风格
2026-08-14 23:21:21 +08:00

184 lines
7.4 KiB
PHP
Raw Permalink 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
namespace App\Service\common\upload;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
/**
* S3 兼容上传AWS / MinIO / 华为 OBS / 百度 BOS
* 使用 AWS Signature Version 4 的 PUT Object
*/
class S3CompatibleStorageService extends BaseNotAuthService implements OssStorageInterface
{
use ObjectKeyNormalizeTrait;
use ObjectListXmlTrait;
protected array $config = [];
public function withConfig(array $config): static
{
$this->config = $config;
return $this;
}
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* ListObjectsV2游标是 continuation-token不是 V1 那种 marker
*/
public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array
{
$this->assertConfig();
$bucket = (string) $this->config['bucket'];
$query = [
'list-type' => '2',
'max-keys' => (string) $this->boundedLimit($limit),
];
$listPrefix = $this->scopedListPrefix($prefix);
if ($listPrefix !== '') {
$query['prefix'] = $listPrefix;
}
if ($marker !== '') {
$query['continuation-token'] = $marker;
}
$response = $this->signedRequest('GET', '/' . $bucket, $query);
if (!$response->successful()) {
UtilsService::getInstance()->errorThrow($this->driver() . ' 列举对象失败:' . $response->body());
}
$endpoint = rtrim((string) $this->config['endpoint'], '/');
return $this->parseObjectListXml($response->body(), $endpoint . '/' . $bucket, 'NextContinuationToken');
}
public function deleteObject(string $key): bool
{
$this->assertConfig();
$key = $this->normalizeObjectKey($key);
if ($key === '') {
return false;
}
$response = $this->signedRequest('DELETE', '/' . $this->config['bucket'] . '/' . $key);
return $response->successful();
}
/**
* SigV4 PUTendpoint 必填(如 https://s3.amazonaws.com 或 MinIO 地址)
*/
private function uploadFile($filePath, string $key): bool|array
{
$this->assertConfig();
$bucket = (string) $this->config['bucket'];
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$payload = file_get_contents($path);
$response = $this->signedRequest('PUT', '/' . $bucket . '/' . $key, [], $payload);
if (!$response->successful()) {
UtilsService::getInstance()->errorThrow($this->driver() . ' 上传失败:' . $response->body());
}
$url = rtrim((string) $this->config['endpoint'], '/') . $this->canonicalUri('/' . $bucket . '/' . $key);
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
return ['key' => $key, 'url' => $publicUrl];
}
/**
* SigV4 签名并发起请求
*
* 上传、列举、删除三条路的差别只是 method / 路径 / 查询参数 / 请求体,
* 签名步骤一模一样,所以收在这里;三份复制粘贴改一处漏两处是必然的。
* 走 path-styleendpoint/bucket/key多数 MinIO 与 OBS 都接受。
*
* @param string $path 未编码的路径,如 /bucket/dir/a.jpg
* @param array<string, string> $query 参与 CanonicalQueryString 的查询参数
*/
private function signedRequest(string $method, string $path, array $query = [], string $payload = ''): Response
{
$accessKey = (string) $this->config['access_key'];
$secretKey = (string) $this->config['secret_key'];
$region = (string) ($this->config['region'] ?? 'us-east-1');
$endpoint = rtrim((string) $this->config['endpoint'], '/');
$host = parse_url($endpoint, PHP_URL_HOST) ?: preg_replace('#^https?://#', '', $endpoint);
$canonicalUri = $this->canonicalUri($path);
ksort($query);
$pairs = [];
foreach ($query as $name => $value) {
$pairs[] = rawurlencode((string) $name) . '=' . rawurlencode((string) $value);
}
$canonicalQuery = implode('&', $pairs);
$amzDate = gmdate('Ymd\THis\Z');
$dateStamp = gmdate('Ymd');
$payloadHash = hash('sha256', $payload);
$canonicalHeaders = "host:{$host}\nx-amz-content-sha256:{$payloadHash}\nx-amz-date:{$amzDate}\n";
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
$canonicalRequest = strtoupper($method) . "\n{$canonicalUri}\n{$canonicalQuery}\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
$credentialScope = "{$dateStamp}/{$region}/s3/aws4_request";
$stringToSign = "AWS4-HMAC-SHA256\n{$amzDate}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest);
$kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $secretKey, true);
$kRegion = hash_hmac('sha256', $region, $kDate, true);
$kService = hash_hmac('sha256', 's3', $kRegion, true);
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
$request = Http::withHeaders([
'Authorization' => "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}",
'x-amz-content-sha256' => $payloadHash,
'x-amz-date' => $amzDate,
'Host' => $host,
]);
$url = $endpoint . $canonicalUri . ($canonicalQuery !== '' ? '?' . $canonicalQuery : '');
return match (strtoupper($method)) {
'PUT' => $request->withBody($payload, 'application/octet-stream')->put($url),
'DELETE' => $request->delete($url),
default => $request->get($url),
};
}
/**
* 逐段编码路径:整段 rawurlencode 会把分隔符 / 也编掉,签名与实际路径就对不上了
*/
private function canonicalUri(string $path): string
{
$segments = array_map(
static fn ($segment) => rawurlencode($segment),
explode('/', ltrim($path, '/'))
);
return '/' . implode('/', $segments);
}
private function driver(): string
{
return (string) ($this->config['driver'] ?? 'aws');
}
private function assertConfig(): void
{
if (trim((string) ($this->config['access_key'] ?? '')) === ''
|| trim((string) ($this->config['secret_key'] ?? '')) === ''
|| trim((string) ($this->config['bucket'] ?? '')) === ''
|| trim((string) ($this->config['endpoint'] ?? '')) === ''
) {
UtilsService::getInstance()->errorThrow(
strtoupper($this->driver()) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint'
);
}
}
}