4b4c18f2d9
Added a shared resumable upload session service in [ResumableUploadService.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/Service/ResumableUploadService.php). Wired dashboard JSON upload endpoints in [AppController.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/Controller/AppController.php). Reworked the upload UI/JS to chunk files directly from the browser to presigned R2 multipart URLs in [app.js](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/public/assets/app.js). Added the dedicated iPhone/Safari side uploader at [side-upload.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/public/side-upload.php), with its own password and fixed destination prefix via script constants, .env, or side-upload.json. Added [side-upload.example.json](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/side-upload.example.json), ignored the real side-upload.json, and documented setup/CORS in [README.md](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/README.md).
619 lines
21 KiB
PHP
619 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace R2Manager\R2;
|
|
|
|
use R2Manager\Support\Config;
|
|
|
|
final class R2Client
|
|
{
|
|
private string $accountId;
|
|
private string $accessKeyId;
|
|
private string $secretAccessKey;
|
|
private string $bucket;
|
|
private string $region;
|
|
private string $endpoint;
|
|
private bool $pathStyle;
|
|
|
|
public function __construct(private readonly Config $config)
|
|
{
|
|
$this->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<array<string, string|int|null>>, prefixes: list<string>, 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<string, string|int|null>
|
|
*/
|
|
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<string, string> $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<string, string> $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<array{PartNumber: int, ETag: string}> $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 = '<CompleteMultipartUpload>';
|
|
foreach ($parts as $part) {
|
|
$xml .= '<Part>';
|
|
$xml .= '<PartNumber>' . (int) $part['PartNumber'] . '</PartNumber>';
|
|
$xml .= '<ETag>' . htmlspecialchars($this->quotedEtag($part['ETag']), ENT_XML1) . '</ETag>';
|
|
$xml .= '</Part>';
|
|
}
|
|
$xml .= '</CompleteMultipartUpload>';
|
|
|
|
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<string, string> $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<string, string> $query
|
|
* @param array<string, string> $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<string, string> $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<string, string> $headers
|
|
* @return array<string, string>
|
|
*/
|
|
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<string, string> $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<int, mixed> $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('<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>');
|
|
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;
|
|
}
|
|
}
|