- Added R2 multipart upload support and streamed fallback uploads in [R2Client.php](/Users/tyemeclifford/Documents/GH/CloudflareR2-Manager/src/R2/R2Client.php).
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).
This commit is contained in:
+101
-6
@@ -128,6 +128,64 @@ final class R2Client
|
||||
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);
|
||||
@@ -180,7 +238,7 @@ final class R2Client
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
public function presign(string $method, string $key, int $expires, array $headers = []): string
|
||||
public function presign(string $method, string $key, int $expires, array $headers = [], array $query = []): string
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
@@ -197,13 +255,13 @@ final class R2Client
|
||||
|
||||
$credentialScope = $dateStamp . '/' . $this->region . '/s3/aws4_request';
|
||||
$signedHeaders = implode(';', array_keys($headers));
|
||||
$query = [
|
||||
$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,
|
||||
@@ -230,18 +288,25 @@ final class R2Client
|
||||
* @param array<string, string> $query
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function request(string $method, string $key = '', array $query = [], array $headers = [], string $body = ''): R2Response
|
||||
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 = hash('sha256', $body);
|
||||
$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);
|
||||
@@ -286,6 +351,7 @@ final class R2Client
|
||||
}
|
||||
|
||||
$handle = curl_init($url);
|
||||
$stream = null;
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => $curlHeaders,
|
||||
@@ -306,12 +372,25 @@ final class R2Client
|
||||
},
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
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);
|
||||
}
|
||||
@@ -319,11 +398,17 @@ final class R2Client
|
||||
$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);
|
||||
@@ -435,6 +520,16 @@ final class R2Client
|
||||
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')) {
|
||||
|
||||
Reference in New Issue
Block a user