初始化
缺陷:主题配色需要优化整体的同风格
This commit is contained in:
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,9 @@ use Illuminate\Support\Facades\Http;
|
||||
*/
|
||||
class S3CompatibleStorageService extends BaseNotAuthService implements OssStorageInterface
|
||||
{
|
||||
use ObjectKeyNormalizeTrait;
|
||||
use ObjectListXmlTrait;
|
||||
|
||||
protected array $config = [];
|
||||
|
||||
public function withConfig(array $config): static
|
||||
@@ -31,21 +35,51 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
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 PUT;endpoint 必填(如 https://s3.amazonaws.com 或 MinIO 地址)
|
||||
*/
|
||||
private function uploadFile($filePath, string $key): bool|array
|
||||
{
|
||||
$accessKey = (string) ($this->config['access_key'] ?? '');
|
||||
$secretKey = (string) ($this->config['secret_key'] ?? '');
|
||||
$bucket = (string) ($this->config['bucket'] ?? '');
|
||||
$region = (string) ($this->config['region'] ?? 'us-east-1');
|
||||
$endpoint = rtrim((string) ($this->config['endpoint'] ?? ''), '/');
|
||||
$this->assertConfig();
|
||||
$bucket = (string) $this->config['bucket'];
|
||||
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
|
||||
$driver = (string) ($this->config['driver'] ?? 'aws');
|
||||
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
|
||||
UtilsService::getInstance()->errorThrow(strtoupper($driver) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)');
|
||||
}
|
||||
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
|
||||
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
|
||||
$key = $prefix . '/' . ltrim($key, '/');
|
||||
@@ -54,37 +88,96 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag
|
||||
? $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-style(endpoint/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);
|
||||
// path-style: endpoint/bucket/key
|
||||
$canonicalUri = '/' . rawurlencode($bucket) . '/' . str_replace('%2F', '/', rawurlencode($key));
|
||||
// 简化:多数 MinIO/OBS 接受 path-style
|
||||
$url = $endpoint . '/' . $bucket . '/' . $key;
|
||||
|
||||
$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 = "PUT\n{$canonicalUri}\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
|
||||
$service = $driver === 'huawei' ? 's3' : 's3';
|
||||
$credentialScope = "{$dateStamp}/{$region}/{$service}/aws4_request";
|
||||
$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', $service, $kRegion, true);
|
||||
$kService = hash_hmac('sha256', 's3', $kRegion, true);
|
||||
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
|
||||
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
|
||||
$authorization = "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}";
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => $authorization,
|
||||
|
||||
$request = Http::withHeaders([
|
||||
'Authorization' => "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}",
|
||||
'x-amz-content-sha256' => $payloadHash,
|
||||
'x-amz-date' => $amzDate,
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Host' => $host,
|
||||
])->withBody($payload, 'application/octet-stream')->put($url);
|
||||
if (!$response->successful()) {
|
||||
UtilsService::getInstance()->errorThrow($driver . ' 上传失败:' . $response->body());
|
||||
]);
|
||||
$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)'
|
||||
);
|
||||
}
|
||||
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
|
||||
return ['key' => $key, 'url' => $publicUrl];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user