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 PUT;endpoint 必填(如 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-style(endpoint/bucket/key),多数 MinIO 与 OBS 都接受。 * * @param string $path 未编码的路径,如 /bucket/dir/a.jpg * @param array $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)' ); } } }