accountId = trim((string) $config->get('R2_ACCOUNT_ID', '')); $this->accessKeyId = trim((string) $config->get('R2_ACCESS_KEY_ID', '')); $this->secretAccessKey = trim((string) $config->get('R2_SECRET_ACCESS_KEY', '')); $this->bucket = trim((string) $config->get('R2_BUCKET', '')); $this->region = trim((string) $config->get('R2_REGION', 'auto')); $this->endpoint = rtrim((string) $config->get('R2_ENDPOINT', ''), '/'); $this->pathStyle = $config->bool('R2_PATH_STYLE', true); if ($this->endpoint === '' && $this->accountId !== '') { $this->endpoint = 'https://' . $this->accountId . '.r2.cloudflarestorage.com'; } } public function isConfigured(): bool { return $this->accountId !== '' && $this->accessKeyId !== '' && $this->secretAccessKey !== '' && $this->bucket !== '' && $this->endpoint !== ''; } public function bucket(): string { return $this->bucket; } /** * @return array{objects: list>, prefixes: list, truncated: bool, next_token: ?string} */ public function listObjectsV2(string $prefix = '', ?string $continuationToken = null, int $maxKeys = 1000): array { $query = [ 'list-type' => '2', 'max-keys' => (string) max(1, min(1000, $maxKeys)), ]; if ($prefix !== '') { $query['prefix'] = $prefix; } if ($continuationToken !== null && $continuationToken !== '') { $query['continuation-token'] = $continuationToken; } $response = $this->request('GET', '', $query); $xml = $this->xml($response->body); $root = $this->xmlChildren($xml); $objects = []; foreach ($root->Contents ?? [] as $content) { $contentNode = $this->xmlChildren($content); $objects[] = [ 'key' => (string) $contentNode->Key, 'last_modified' => (string) $contentNode->LastModified, 'etag' => trim((string) $contentNode->ETag, '"'), 'size' => (int) $contentNode->Size, 'storage_class' => isset($contentNode->StorageClass) ? (string) $contentNode->StorageClass : null, ]; } $prefixes = []; foreach ($root->CommonPrefixes ?? [] as $commonPrefix) { $prefixNode = $this->xmlChildren($commonPrefix); $prefixes[] = (string) $prefixNode->Prefix; } return [ 'objects' => $objects, 'prefixes' => $prefixes, 'truncated' => strtolower((string) ($root->IsTruncated ?? 'false')) === 'true', 'next_token' => isset($root->NextContinuationToken) ? (string) $root->NextContinuationToken : null, ]; } /** * @return array */ public function headObject(string $key): array { $response = $this->request('HEAD', $key); return [ 'key' => $key, 'etag' => trim((string) $response->header('etag', ''), '"'), 'size' => (int) ($response->header('content-length', '0') ?? 0), 'content_type' => $response->header('content-type'), 'last_modified' => $this->normalizeHttpDate($response->header('last-modified')), 'storage_class' => $response->header('x-amz-storage-class'), ]; } public function getObject(string $key): R2Response { return $this->request('GET', $key); } /** * @param array $metadata */ public function putObject(string $key, string $body, string $contentType = 'application/octet-stream', array $metadata = []): R2Response { $headers = ['Content-Type' => $contentType]; foreach ($metadata as $name => $value) { $headers['x-amz-meta-' . strtolower($name)] = $value; } return $this->request('PUT', $key, [], $headers, $body); } /** * @param array $metadata */ public function putObjectFromFile(string $key, string $path, string $contentType = 'application/octet-stream', array $metadata = []): R2Response { $headers = ['Content-Type' => $contentType]; foreach ($metadata as $name => $value) { $headers['x-amz-meta-' . strtolower($name)] = $value; } return $this->request('PUT', $key, [], $headers, '', $path); } public function createMultipartUpload(string $key, string $contentType = 'application/octet-stream'): string { $response = $this->request('POST', $key, ['uploads' => ''], ['Content-Type' => $contentType]); $xml = $this->xml($response->body); $root = $this->xmlChildren($xml); $uploadId = (string) ($root->UploadId ?? ''); if ($uploadId === '') { throw new R2Exception('R2 did not return a multipart upload ID.'); } return $uploadId; } /** * @param list $parts */ public function completeMultipartUpload(string $key, string $uploadId, array $parts): R2Response { usort($parts, static fn (array $a, array $b): int => $a['PartNumber'] <=> $b['PartNumber']); $xml = ''; foreach ($parts as $part) { $xml .= ''; $xml .= '' . (int) $part['PartNumber'] . ''; $xml .= '' . htmlspecialchars($this->quotedEtag($part['ETag']), ENT_XML1) . ''; $xml .= ''; } $xml .= ''; return $this->request('POST', $key, ['uploadId' => $uploadId], ['Content-Type' => 'application/xml'], $xml); } public function abortMultipartUpload(string $key, string $uploadId): R2Response { return $this->request('DELETE', $key, ['uploadId' => $uploadId]); } public function presignUploadPart(string $key, string $uploadId, int $partNumber, int $expires = 3600): string { return $this->presign('PUT', $key, $expires, [], [ 'partNumber' => (string) $partNumber, 'uploadId' => $uploadId, ]); } public function deleteObject(string $key): R2Response { return $this->request('DELETE', $key); } public function copyObject(string $sourceKey, string $destinationKey, ?string $contentType = null): R2Response { $headers = [ 'x-amz-copy-source' => '/' . $this->bucket . '/' . $this->encodeKey($sourceKey), ]; if ($contentType !== null && $contentType !== '') { $headers['Content-Type'] = $contentType; $headers['x-amz-metadata-directive'] = 'REPLACE'; } return $this->request('PUT', $destinationKey, [], $headers); } public function getBucketCors(): ?string { try { return $this->request('GET', '', ['cors' => ''])->body; } catch (R2Exception $exception) { if ($exception->statusCode() === 404) { return null; } throw $exception; } } public function putBucketCorsJson(string $json): R2Response { $rules = json_decode($json, true); if (!is_array($rules)) { throw new R2Exception('CORS JSON must be an array of rules.'); } $xml = $this->corsJsonToXml($rules); return $this->request('PUT', '', ['cors' => ''], ['Content-Type' => 'application/xml'], $xml); } public function deleteBucketCors(): R2Response { return $this->request('DELETE', '', ['cors' => '']); } /** * @param array $headers */ public function presign(string $method, string $key, int $expires, array $headers = [], array $query = []): string { $this->assertConfigured(); $method = strtoupper($method); $expires = max(1, min(604800, $expires)); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $amzDate = $now->format('Ymd\THis\Z'); $dateStamp = $now->format('Ymd'); $urlParts = $this->urlParts($key); $headers = $this->normalizeHeaders($headers); $headers['host'] = $urlParts['host']; ksort($headers); $credentialScope = $dateStamp . '/' . $this->region . '/s3/aws4_request'; $signedHeaders = implode(';', array_keys($headers)); $query = array_merge($query, [ 'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256', 'X-Amz-Credential' => $this->accessKeyId . '/' . $credentialScope, 'X-Amz-Date' => $amzDate, 'X-Amz-Expires' => (string) $expires, 'X-Amz-SignedHeaders' => $signedHeaders, ]); $canonicalRequest = implode("\n", [ $method, $urlParts['path'], $this->canonicalQueryString($query), $this->canonicalHeaders($headers), $signedHeaders, 'UNSIGNED-PAYLOAD', ]); $stringToSign = implode("\n", [ 'AWS4-HMAC-SHA256', $amzDate, $credentialScope, hash('sha256', $canonicalRequest), ]); $query['X-Amz-Signature'] = hash_hmac('sha256', $stringToSign, $this->signingKey($dateStamp)); return $urlParts['base'] . $urlParts['path'] . '?' . $this->canonicalQueryString($query); } /** * @param array $query * @param array $headers */ private function request(string $method, string $key = '', array $query = [], array $headers = [], string $body = '', ?string $bodyFile = null): R2Response { $this->assertConfigured(); if (!function_exists('curl_init')) { throw new R2Exception('The PHP curl extension is required to talk to R2.'); } if ($bodyFile !== null && !is_file($bodyFile)) { throw new R2Exception('Upload source file could not be read.'); } $method = strtoupper($method); $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $amzDate = $now->format('Ymd\THis\Z'); $dateStamp = $now->format('Ymd'); $payloadHash = $bodyFile === null ? hash('sha256', $body) : hash_file('sha256', $bodyFile); if (!is_string($payloadHash)) { throw new R2Exception('Could not hash upload source file.'); } $urlParts = $this->urlParts($key); $headers = $this->normalizeHeaders($headers); $headers['host'] = $urlParts['host']; $headers['x-amz-content-sha256'] = $payloadHash; $headers['x-amz-date'] = $amzDate; ksort($headers); $signedHeaders = implode(';', array_keys($headers)); $credentialScope = $dateStamp . '/' . $this->region . '/s3/aws4_request'; $canonicalRequest = implode("\n", [ $method, $urlParts['path'], $this->canonicalQueryString($query), $this->canonicalHeaders($headers), $signedHeaders, $payloadHash, ]); $stringToSign = implode("\n", [ 'AWS4-HMAC-SHA256', $amzDate, $credentialScope, hash('sha256', $canonicalRequest), ]); $signature = hash_hmac('sha256', $stringToSign, $this->signingKey($dateStamp)); $headers['authorization'] = 'AWS4-HMAC-SHA256 Credential=' . $this->accessKeyId . '/' . $credentialScope . ', SignedHeaders=' . $signedHeaders . ', Signature=' . $signature; $url = $urlParts['base'] . $urlParts['path']; $queryString = $this->canonicalQueryString($query); if ($queryString !== '') { $url .= '?' . $queryString; } $responseHeaders = []; $curlHeaders = []; foreach ($headers as $name => $value) { $curlHeaders[] = $name . ': ' . $value; } $handle = curl_init($url); $stream = null; curl_setopt_array($handle, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $curlHeaders, CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int { $length = strlen($line); $line = trim($line); if ($line === '' || str_contains($line, 'HTTP/')) { return $length; } $position = strpos($line, ':'); if ($position !== false) { $name = strtolower(trim(substr($line, 0, $position))); $responseHeaders[$name] = trim(substr($line, $position + 1)); } return $length; }, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => max(0, $this->config->int('R2_REQUEST_TIMEOUT', 300)), CURLOPT_FOLLOWLOCATION => false, ]); if ($method === 'HEAD') { curl_setopt($handle, CURLOPT_NOBODY, true); } elseif ($bodyFile !== null) { $stream = fopen($bodyFile, 'rb'); $size = filesize($bodyFile); if (!is_resource($stream) || $size === false) { if (is_resource($stream)) { fclose($stream); } curl_close($handle); throw new R2Exception('Upload source file could not be opened.'); } curl_setopt($handle, CURLOPT_UPLOAD, true); curl_setopt($handle, CURLOPT_INFILE, $stream); curl_setopt($handle, CURLOPT_INFILESIZE, $size); } elseif ($body !== '' || in_array($method, ['PUT', 'POST'], true)) { curl_setopt($handle, CURLOPT_POSTFIELDS, $body); } $rawBody = curl_exec($handle); if ($rawBody === false) { $error = curl_error($handle); if (is_resource($stream)) { fclose($stream); } curl_close($handle); throw new R2Exception('R2 request failed: ' . $error); } $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); if (is_resource($stream)) { fclose($stream); } curl_close($handle); $response = new R2Response($status, $responseHeaders, (string) $rawBody); if ($status < 200 || $status >= 300) { throw new R2Exception($this->errorMessage($response), $status, $response->body); } return $response; } private function assertConfigured(): void { if (!$this->isConfigured()) { throw new R2Exception('R2 is not configured. Add your account ID, access keys, and bucket to .env.'); } } /** * @return array{base: string, host: string, path: string} */ private function urlParts(string $key): array { $parts = parse_url($this->endpoint); $scheme = $parts['scheme'] ?? 'https'; $host = $parts['host'] ?? ''; $port = isset($parts['port']) ? ':' . $parts['port'] : ''; if ($host === '') { throw new R2Exception('R2_ENDPOINT must include a hostname.'); } if (!$this->pathStyle) { $host = $this->bucket . '.' . $host; $path = '/' . $this->encodeKey($key); if ($path === '/') { $path = '/'; } } else { $path = '/' . rawurlencode($this->bucket); if ($key !== '') { $path .= '/' . $this->encodeKey($key); } } return [ 'base' => $scheme . '://' . $host . $port, 'host' => $host . $port, 'path' => $path, ]; } private function encodeKey(string $key): string { if ($key === '') { return ''; } return implode('/', array_map('rawurlencode', explode('/', $key))); } /** * @param array $query */ private function canonicalQueryString(array $query): string { $pairs = []; foreach ($query as $name => $value) { $pairs[] = [rawurlencode((string) $name), rawurlencode((string) $value)]; } usort($pairs, static fn (array $a, array $b): int => $a[0] === $b[0] ? strcmp($a[1], $b[1]) : strcmp($a[0], $b[0])); return implode('&', array_map(static fn (array $pair): string => $pair[0] . '=' . $pair[1], $pairs)); } /** * @param array $headers * @return array */ private function normalizeHeaders(array $headers): array { $normalized = []; foreach ($headers as $name => $value) { $normalized[strtolower(trim((string) $name))] = trim(preg_replace('/\s+/', ' ', (string) $value) ?? ''); } return array_filter($normalized, static fn (string $value): bool => $value !== ''); } /** * @param array $headers */ private function canonicalHeaders(array $headers): string { ksort($headers); $lines = []; foreach ($headers as $name => $value) { $lines[] = strtolower($name) . ':' . trim($value); } return implode("\n", $lines) . "\n"; } private function signingKey(string $dateStamp): string { $dateKey = hash_hmac('sha256', $dateStamp, 'AWS4' . $this->secretAccessKey, true); $dateRegionKey = hash_hmac('sha256', $this->region, $dateKey, true); $dateRegionServiceKey = hash_hmac('sha256', 's3', $dateRegionKey, true); return hash_hmac('sha256', 'aws4_request', $dateRegionServiceKey, true); } private function quotedEtag(string $etag): string { $etag = trim($etag); if ($etag === '') { return '""'; } return str_starts_with($etag, '"') && str_ends_with($etag, '"') ? $etag : '"' . trim($etag, '"') . '"'; } private function xml(string $body): \SimpleXMLElement { if (!function_exists('simplexml_load_string')) { throw new R2Exception('The PHP SimpleXML extension is required to parse R2 responses.'); } $xml = @simplexml_load_string($body); if (!$xml instanceof \SimpleXMLElement) { throw new R2Exception('R2 returned invalid XML.'); } return $xml; } /** * @param array $rules */ private function corsJsonToXml(array $rules): string { if (!class_exists(\SimpleXMLElement::class)) { throw new R2Exception('The PHP SimpleXML extension is required to write CORS XML.'); } $xml = new \SimpleXMLElement(''); foreach ($rules as $rule) { if (!is_array($rule)) { continue; } $corsRule = $xml->addChild('CORSRule'); foreach ((array) ($rule['AllowedOrigins'] ?? []) as $origin) { $corsRule->addChild('AllowedOrigin', htmlspecialchars((string) $origin, ENT_XML1)); } foreach ((array) ($rule['AllowedMethods'] ?? []) as $method) { $corsRule->addChild('AllowedMethod', htmlspecialchars(strtoupper((string) $method), ENT_XML1)); } foreach ((array) ($rule['AllowedHeaders'] ?? []) as $header) { $corsRule->addChild('AllowedHeader', htmlspecialchars((string) $header, ENT_XML1)); } foreach ((array) ($rule['ExposeHeaders'] ?? []) as $header) { $corsRule->addChild('ExposeHeader', htmlspecialchars((string) $header, ENT_XML1)); } if (isset($rule['MaxAgeSeconds']) && is_numeric($rule['MaxAgeSeconds'])) { $corsRule->addChild('MaxAgeSeconds', (string) (int) $rule['MaxAgeSeconds']); } } return $xml->asXML() ?: ''; } private function normalizeHttpDate(?string $date): ?string { if ($date === null || $date === '') { return null; } $timestamp = strtotime($date); return $timestamp === false ? null : gmdate('c', $timestamp); } private function errorMessage(R2Response $response): string { $body = trim($response->body); if ($body === '') { return 'R2 returned HTTP ' . $response->status . '.'; } if (function_exists('simplexml_load_string')) { $xml = @simplexml_load_string($body); if ($xml instanceof \SimpleXMLElement) { $root = $this->xmlChildren($xml); if (isset($root->Message)) { return 'R2 returned HTTP ' . $response->status . ': ' . (string) $root->Message; } } } return 'R2 returned HTTP ' . $response->status . ': ' . substr($body, 0, 500); } private function xmlChildren(\SimpleXMLElement $xml): \SimpleXMLElement { $namespace = $xml->getDocNamespaces(true)[''] ?? null; return $namespace ? $xml->children($namespace) : $xml; } }