diff --git a/.env.example b/.env.example index 26004b3..1f54379 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ R2_BUCKET="" R2_REGION="auto" R2_ENDPOINT="" R2_PATH_STYLE=true +R2_REQUEST_TIMEOUT=300 # Optional public bucket or custom domain URL, for display/copy links only. R2_PUBLIC_URL="" @@ -18,3 +19,15 @@ R2_PUBLIC_URL="" DB_PATH="storage/database/r2-manager.sqlite" SYNC_PAGE_LIMIT=1000 +# Resumable browser uploads use R2 multipart upload URLs. +# UPLOAD_PART_SIZE is bytes and must stay at or above 5242880 for R2/S3 multipart uploads. +UPLOAD_PART_SIZE=8388608 +UPLOAD_PART_URL_TTL=3600 +UPLOAD_SESSION_TTL=604800 + +# Optional quick side uploader at /side-upload.php. This password is independent from APP_PASSWORD. +# You can also copy side-upload.example.json to side-upload.json and set these there. +# SIDE_UPLOAD_PASSWORD="choose-a-different-password" +# SIDE_UPLOAD_PASSWORD_HASH="$2y$10$replace-with-side-password-hash" +SIDE_UPLOAD_PREFIX="iphone-uploads/" +SIDE_UPLOAD_TAGS="source=iphone" diff --git a/.gitignore b/.gitignore index e9b0a02..dcc4a9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ .env +side-upload.json storage/database/*.sqlite storage/database/*.sqlite-* storage/cache/* !storage/cache/.gitkeep !storage/database/.gitkeep .DS_Store - diff --git a/README.md b/README.md index 436146b..da4ad3f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A self-contained PHP web application for managing a Cloudflare R2 bucket with a - R2 object browser backed by SQLite, so normal navigation uses the local mirror instead of repeatedly listing the bucket. - Prefix/folder organization, including zero-byte marker objects for empty folders. - Upload one or more files directly to R2 with local tags, permission labels, and operator notes. +- Resumable browser uploads using R2 multipart upload URLs, suitable for large files and mobile Safari. +- Optional side upload page with its own password and fixed destination prefix for quick iPhone uploads. - Download, delete, copy, move, rename, and refresh object metadata. - Generate presigned `GET`, `PUT`, `HEAD`, and `DELETE` URLs. - Manage bucket CORS from JSON. @@ -55,6 +57,37 @@ Composer is not required. 4. Open `http://127.0.0.1:8080`, sign in, and run **Sync current prefix**. +## Resumable Uploads + +The dashboard upload form uses browser-side R2 multipart uploads when JavaScript is available. PHP creates the multipart upload, signs each part URL, records completed part ETags in `storage/cache/uploads`, and completes the upload when all parts are present. If a connection drops, choose the same file again and the app will reuse the saved multipart session. + +Useful settings: + +```dotenv +UPLOAD_PART_SIZE=8388608 +UPLOAD_PART_URL_TTL=3600 +UPLOAD_SESSION_TTL=604800 +R2_REQUEST_TIMEOUT=300 +``` + +`UPLOAD_PART_SIZE` is bytes. Keep it at or above `5242880` because S3-compatible multipart uploads require 5 MiB minimum parts except for the last part. + +Because file chunks upload from the browser directly to R2, your bucket CORS policy must allow the app origin to `PUT` and expose `ETag`. The default CORS editor example already includes `PUT` and `ETag`; change `AllowedOrigins` to your deployed app origin. + +## Side Upload Page + +`/side-upload.php` is a narrow upload-only page for quick mobile uploads. It uses a separate password from the main app and always uploads into a configured prefix. + +Configure it in `.env`: + +```dotenv +SIDE_UPLOAD_PASSWORD="choose-a-different-password" +SIDE_UPLOAD_PREFIX="iphone-uploads/" +SIDE_UPLOAD_TAGS="source=iphone" +``` + +You can also edit the constants at the top of `public/side-upload.php`. Or copy `side-upload.example.json` to `side-upload.json` and edit the password and prefix there. The real `side-upload.json` file is ignored by git. + ## Password Hashes For a plain setup, use `APP_PASSWORD`. For a hashed password, generate a hash with PHP and set `APP_PASSWORD_HASH` instead: @@ -99,4 +132,3 @@ Enter CORS as an array of S3-style rules: - Serve the app behind HTTPS in production. - Use least-privilege R2 credentials. - Restrict access at your web server or VPN if this manages production buckets. - diff --git a/public/assets/app.css b/public/assets/app.css index 9a33b22..777948d 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -496,6 +496,70 @@ code { font-size: 12px; } +.upload-progress { + display: grid; + gap: 10px; +} + +.upload-item, +.upload-message { + display: grid; + gap: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-muted); + padding: 12px; +} + +.upload-item-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.upload-item-header strong { + overflow-wrap: anywhere; +} + +.upload-item-header span, +.upload-item small { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.upload-item progress { + width: 100%; + height: 12px; + accent-color: var(--primary); +} + +.upload-message-error { + border-color: #f1aaa4; + background: #fff1f0; + color: var(--danger); +} + +.side-upload-shell { + display: grid; + gap: 16px; + width: min(620px, calc(100% - 24px)); + margin: 20px auto 44px; +} + +.destination-pill { + display: block; + overflow-wrap: anywhere; + border-radius: 8px; + background: var(--surface-muted); + padding: 10px 12px; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; + font-weight: 650; +} + @media (max-width: 1100px) { .workspace-grid, .stats-grid, diff --git a/public/assets/app.js b/public/assets/app.js index 585da03..46adee4 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -12,3 +12,260 @@ document.addEventListener('focus', (event) => { } }, true); +document.querySelectorAll('[data-resumable-upload]').forEach((form) => { + form.addEventListener('submit', async (event) => { + const fileInput = form.querySelector('input[type="file"]'); + const files = Array.from(fileInput?.files || []); + if (files.length === 0 || typeof fetch !== 'function' || typeof FormData !== 'function') { + return; + } + + event.preventDefault(); + + const button = form.querySelector('button[type="submit"]'); + const progress = form.querySelector('[data-upload-progress]'); + const endpoint = form.dataset.uploadEndpoint || form.getAttribute('action') || window.location.href; + const originalText = button ? button.textContent : ''; + + if (button) { + button.disabled = true; + button.textContent = 'Uploading...'; + } + if (progress) { + progress.replaceChildren(); + } + + try { + for (const file of files) { + await uploadFile(form, endpoint, file, progress); + } + + if (button) { + button.textContent = 'Uploaded'; + } + if (form.dataset.uploadReload === 'true') { + window.setTimeout(() => window.location.reload(), 900); + } else { + if (fileInput) { + fileInput.value = ''; + } + if (button) { + window.setTimeout(() => { + button.disabled = false; + button.textContent = originalText; + }, 900); + } + } + } catch (error) { + showUploadMessage(progress, error instanceof Error ? error.message : 'Upload failed.', true); + if (button) { + button.disabled = false; + button.textContent = originalText; + } + } + }); +}); + +async function uploadFile(form, endpoint, file, progressRoot) { + const row = createUploadRow(file, progressRoot); + const fingerprint = await fileFingerprint(form, file); + const start = await uploadPost(form, endpoint, 'resumable_start', { + file_name: file.name, + file_size: String(file.size), + content_type: file.type || 'application/octet-stream', + fingerprint, + }); + + if (start.completed) { + updateUploadRow(row, file.size, file.size, 'Complete'); + return; + } + + const partSize = Number(start.partSize || 0); + const totalParts = Number(start.totalParts || 0); + if (!start.id || partSize < 1 || totalParts < 1) { + throw new Error('Upload session did not return a usable part plan.'); + } + + let uploadedBytes = uploadedByteCount(start.uploadedParts || {}, file.size, partSize, totalParts); + updateUploadRow(row, uploadedBytes, file.size, resumeLabel(uploadedBytes)); + + for (let partNumber = 1; partNumber <= totalParts; partNumber++) { + const key = String(partNumber); + const startByte = (partNumber - 1) * partSize; + const endByte = Math.min(file.size, startByte + partSize); + + if (!start.uploadedParts || !start.uploadedParts[key]) { + const part = await uploadPost(form, endpoint, 'resumable_part_url', { + id: start.id, + part_number: key, + }); + + updateUploadRow(row, uploadedBytes, file.size, `Part ${partNumber} of ${totalParts}`); + const response = await fetch(part.url, { + method: 'PUT', + body: file.slice(startByte, endByte), + }); + + if (!response.ok) { + throw new Error(`R2 rejected part ${partNumber} with HTTP ${response.status}.`); + } + + const etag = response.headers.get('ETag') || response.headers.get('etag'); + if (!etag) { + throw new Error('R2 accepted a part, but the browser could not read the ETag. Add ETag to the bucket CORS ExposeHeaders list.'); + } + + const marked = await uploadPost(form, endpoint, 'resumable_part_done', { + id: start.id, + part_number: key, + etag, + }); + start.uploadedParts = marked.uploadedParts || start.uploadedParts || {}; + } + + uploadedBytes = Math.max(uploadedBytes, endByte); + updateUploadRow(row, uploadedBytes, file.size, `Part ${partNumber} of ${totalParts}`); + } + + await uploadPost(form, endpoint, 'resumable_complete', { id: start.id }); + updateUploadRow(row, file.size, file.size, 'Complete'); +} + +async function uploadPost(form, endpoint, action, values = {}) { + const body = new FormData(); + body.append('action', action); + appendField(form, body, 'csrf'); + appendField(form, body, 'prefix'); + appendField(form, body, 'tags'); + appendField(form, body, 'permission'); + appendField(form, body, 'notes'); + + Object.entries(values).forEach(([name, value]) => { + body.set(name, value); + }); + + const response = await fetch(endpoint, { + method: 'POST', + body, + credentials: 'same-origin', + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Upload request failed with HTTP ${response.status}.`); + } + + return payload; +} + +function appendField(form, body, name) { + const field = form.elements[name]; + if (!field) { + return; + } + + body.append(name, field.value || ''); +} + +async function fileFingerprint(form, file) { + const seed = [ + fieldValue(form, 'prefix'), + file.name, + file.size, + file.lastModified, + file.type || '', + ].join('|'); + + if (window.crypto?.subtle && window.TextEncoder) { + const digest = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); + } + + return seed; +} + +function fieldValue(form, name) { + const field = form.elements[name]; + return field ? field.value || '' : ''; +} + +function uploadedByteCount(parts, size, partSize, totalParts) { + let bytes = 0; + for (let partNumber = 1; partNumber <= totalParts; partNumber++) { + if (parts[String(partNumber)]) { + const startByte = (partNumber - 1) * partSize; + bytes += Math.min(size, startByte + partSize) - startByte; + } + } + + return Math.min(size, bytes); +} + +function resumeLabel(uploadedBytes) { + return uploadedBytes > 0 ? 'Resuming' : 'Starting'; +} + +function createUploadRow(file, root) { + if (!root) { + return null; + } + + const item = document.createElement('div'); + item.className = 'upload-item'; + + const header = document.createElement('div'); + header.className = 'upload-item-header'; + + const name = document.createElement('strong'); + name.textContent = file.name; + + const status = document.createElement('span'); + status.textContent = `0 / ${formatBytes(file.size)}`; + + const bar = document.createElement('progress'); + bar.max = file.size || 1; + bar.value = 0; + + const detail = document.createElement('small'); + detail.textContent = 'Waiting'; + + header.append(name, status); + item.append(header, bar, detail); + root.append(item); + + return { bar, detail, status }; +} + +function updateUploadRow(row, uploadedBytes, totalBytes, detail) { + if (!row) { + return; + } + + row.bar.max = totalBytes || 1; + row.bar.value = Math.min(uploadedBytes, totalBytes || 1); + row.status.textContent = `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`; + row.detail.textContent = detail; +} + +function showUploadMessage(root, message, isError = false) { + if (!root) { + window.alert(message); + return; + } + + const item = document.createElement('div'); + item.className = `upload-message${isError ? ' upload-message-error' : ''}`; + item.textContent = message; + root.append(item); +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / 1024 ** index; + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} diff --git a/public/side-upload.php b/public/side-upload.php new file mode 100644 index 0000000..dd9fcb9 --- /dev/null +++ b/public/side-upload.php @@ -0,0 +1,364 @@ + + + + + + + <?= h($appName) ?> + + + +
+ + R2 + + + +
+ + + +
+ +
+ + +
+
+
+ + +
+ +
+ Side upload is disabled. + Set SIDE_UPLOAD_PASSWORD or SIDE_UPLOAD_PASSWORD_HASH in this script, .env, or side-upload.json. +
+ +
+ + +
+

Quick upload

+

+

Sign in with the side-upload password.

+
+ + +
+ +
+
+
+

Destination

+

+
+
+ +
+ +
+ + + + + +
+
+ +
+ + + + + + */ +function side_upload_config(Config $config): array +{ + $side = [ + 'app_name' => 'Side Upload', + 'password' => SIDE_UPLOAD_PASSWORD, + 'password_hash' => SIDE_UPLOAD_PASSWORD_HASH, + 'prefix' => SIDE_UPLOAD_PREFIX, + 'session_name' => 'r2_side_upload_session', + 'permission' => 'private', + 'tags' => 'source=side-upload', + ]; + + if (is_file(SIDE_UPLOAD_CONFIG_FILE)) { + $json = json_decode((string) file_get_contents(SIDE_UPLOAD_CONFIG_FILE), true); + if (is_array($json)) { + $side = array_replace($side, $json); + } + } + + foreach ([ + 'SIDE_UPLOAD_APP_NAME' => 'app_name', + 'SIDE_UPLOAD_PASSWORD' => 'password', + 'SIDE_UPLOAD_PASSWORD_HASH' => 'password_hash', + 'SIDE_UPLOAD_PREFIX' => 'prefix', + 'SIDE_UPLOAD_SESSION_NAME' => 'session_name', + 'SIDE_UPLOAD_PERMISSION' => 'permission', + 'SIDE_UPLOAD_TAGS' => 'tags', + ] as $env => $key) { + $value = $config->get($env, ''); + if ($value !== null && $value !== '') { + $side[$key] = $value; + } + } + + $side['prefix'] = R2Key::normalizePrefix((string) $side['prefix']); + return $side; +} + +function side_upload_start_session(string $name): void +{ + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + + session_name($name !== '' ? $name : 'r2_side_upload_session'); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); +} + +function side_upload_csrf(): string +{ + if (empty($_SESSION['side_upload_csrf'])) { + $_SESSION['side_upload_csrf'] = bin2hex(random_bytes(32)); + } + + return (string) $_SESSION['side_upload_csrf']; +} + +function side_upload_validate_csrf(mixed $token): bool +{ + return is_string($token) && hash_equals(side_upload_csrf(), $token); +} + +/** + * @param array $side + */ +function side_upload_enabled(array $side): bool +{ + return trim((string) ($side['password'] ?? '')) !== '' || trim((string) ($side['password_hash'] ?? '')) !== ''; +} + +/** + * @param array $side + */ +function side_upload_verify_password(string $password, array $side): bool +{ + $hash = trim((string) ($side['password_hash'] ?? '')); + if ($hash !== '') { + return password_verify($password, $hash); + } + + return hash_equals((string) ($side['password'] ?? ''), $password); +} + +function side_upload_is_resumable_action(string $action): bool +{ + return in_array($action, [ + 'resumable_start', + 'resumable_part_url', + 'resumable_part_done', + 'resumable_complete', + 'resumable_abort', + ], true); +} + +/** + * @param array $side + */ +function side_upload_handle_resumable(string $action, string $csrf, array $side, ResumableUploadService $uploads): void +{ + if (!side_upload_validate_csrf($_POST['csrf'] ?? null) || !hash_equals($csrf, side_upload_csrf())) { + side_upload_json(['ok' => false, 'error' => 'Your session token expired. Refresh and try again.'], 419); + return; + } + + if (empty($_SESSION['side_upload_authenticated'])) { + side_upload_json(['ok' => false, 'error' => 'Sign in again before uploading.'], 401); + return; + } + + try { + $id = (string) ($_POST['id'] ?? ''); + $partNumber = (int) ($_POST['part_number'] ?? 0); + switch ($action) { + case 'resumable_start': + side_upload_json($uploads->start( + (string) $side['prefix'], + (string) ($_POST['file_name'] ?? ''), + (int) ($_POST['file_size'] ?? 0), + (string) ($_POST['content_type'] ?? ''), + (string) ($_POST['fingerprint'] ?? ''), + side_upload_tags($side), + side_upload_permission((string) ($side['permission'] ?? 'private')), + trim((string) ($_POST['notes'] ?? '')), + 'side-upload' + )); + break; + case 'resumable_part_url': + side_upload_json($uploads->partUrl($id, $partNumber)); + break; + case 'resumable_part_done': + side_upload_json($uploads->markPart($id, $partNumber, (string) ($_POST['etag'] ?? ''))); + break; + case 'resumable_complete': + side_upload_json($uploads->complete($id)); + break; + case 'resumable_abort': + side_upload_json($uploads->abort($id)); + break; + } + } catch (R2Exception $exception) { + side_upload_json(['ok' => false, 'error' => $exception->getMessage()], $exception->statusCode() ?: 500); + } catch (Throwable $exception) { + side_upload_json(['ok' => false, 'error' => $exception->getMessage()], 500); + } +} + +/** + * @param array $side + * @return array + */ +function side_upload_tags(array $side): array +{ + $tags = $side['tags'] ?? 'source=side-upload'; + if (is_array($tags)) { + $normalized = []; + foreach ($tags as $name => $value) { + $normalized[(string) $name] = (string) $value; + } + return $normalized; + } + + return Tags::parse((string) $tags); +} + +function side_upload_permission(string $permission): string +{ + return in_array($permission, ['private', 'public-read', 'signed-only', 'archived'], true) ? $permission : 'private'; +} + +/** + * @param array $payload + */ +function side_upload_json(array $payload, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{"ok":false}'; +} + +function side_upload_flash(): string +{ + $message = (string) ($_SESSION['side_upload_flash'] ?? ''); + unset($_SESSION['side_upload_flash']); + return $message; +} + +function side_upload_set_flash(string $message): void +{ + $_SESSION['side_upload_flash'] = $message; +} + +function side_upload_redirect(): void +{ + header('Location: side-upload.php'); + exit; +} + +function h(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); +} diff --git a/side-upload.example.json b/side-upload.example.json new file mode 100644 index 0000000..d568dc3 --- /dev/null +++ b/side-upload.example.json @@ -0,0 +1,8 @@ +{ + "app_name": "iPhone Upload", + "password": "replace-with-a-different-password", + "password_hash": "", + "prefix": "iphone-uploads/", + "permission": "private", + "tags": "source=iphone" +} diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 1f6a765..07c6964 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -9,6 +9,7 @@ use R2Manager\R2\R2Exception; use R2Manager\Repository\ObjectRepository; use R2Manager\Repository\SettingsRepository; use R2Manager\Repository\UsageRepository; +use R2Manager\Service\ResumableUploadService; use R2Manager\Support\Config; use R2Manager\Support\Flash; use R2Manager\Support\Formatter; @@ -60,8 +61,14 @@ final class AppController { $action = (string) ($_POST['action'] ?? ''); $prefix = R2Key::normalizePrefix($_POST['prefix'] ?? $_GET['prefix'] ?? ''); + $isResumableAction = $this->isResumableAction($action); if (!$this->security->validateCsrf($_POST['csrf'] ?? null)) { + if ($isResumableAction) { + $this->json(['ok' => false, 'error' => 'Your session token expired. Refresh and try again.'], 419); + return; + } + Flash::add('error', 'Your session token expired. Try again.'); $this->redirect(['prefix' => $prefix]); } @@ -77,9 +84,28 @@ final class AppController } if (!$this->security->isAuthenticated()) { + if ($isResumableAction) { + $this->json(['ok' => false, 'error' => 'Sign in again before uploading.'], 401); + return; + } + $this->redirect(); } + if ($isResumableAction) { + try { + $this->handleResumableUpload($action, $prefix); + } catch (R2Exception $exception) { + $this->json(['ok' => false, 'error' => $exception->getMessage()], $exception->statusCode() ?: 500); + } catch (Throwable $exception) { + $this->json([ + 'ok' => false, + 'error' => $this->config->bool('APP_DEBUG') ? $exception->getMessage() : 'Something went wrong while uploading.', + ], 500); + } + return; + } + try { switch ($action) { case 'logout': @@ -225,14 +251,15 @@ final class AppController } $key = R2Key::join($prefix, $name); - $body = file_get_contents((string) $file['tmp_name']); - if ($body === false) { + $path = (string) $file['tmp_name']; + if (!is_file($path)) { continue; } - $contentType = $this->contentType((string) $file['tmp_name'], (string) ($file['type'] ?? '')); - $response = $this->r2->putObject($key, $body, $contentType); - $size = strlen($body); + $contentType = $this->contentType($path, (string) ($file['type'] ?? '')); + $response = $this->r2->putObjectFromFile($key, $path, $contentType); + $fileSize = filesize($path); + $size = $fileSize === false ? (int) ($file['size'] ?? 0) : $fileSize; $bytes += $size; $uploaded++; @@ -251,6 +278,41 @@ final class AppController Flash::add($uploaded > 0 ? 'success' : 'error', $uploaded > 0 ? 'Uploaded ' . number_format($uploaded) . ' file(s).' : 'No files were uploaded.'); } + private function handleResumableUpload(string $action, string $prefix): void + { + $uploads = $this->resumableUploads(); + $id = (string) ($_POST['id'] ?? ''); + $partNumber = (int) ($_POST['part_number'] ?? 0); + + switch ($action) { + case 'resumable_start': + $this->json($uploads->start( + $prefix, + (string) ($_POST['file_name'] ?? ''), + (int) ($_POST['file_size'] ?? 0), + (string) ($_POST['content_type'] ?? ''), + (string) ($_POST['fingerprint'] ?? ''), + Tags::parse((string) ($_POST['tags'] ?? '')), + $this->permission((string) ($_POST['permission'] ?? 'private')), + trim((string) ($_POST['notes'] ?? '')), + 'dashboard' + )); + break; + case 'resumable_part_url': + $this->json($uploads->partUrl($id, $partNumber)); + break; + case 'resumable_part_done': + $this->json($uploads->markPart($id, $partNumber, (string) ($_POST['etag'] ?? ''))); + break; + case 'resumable_complete': + $this->json($uploads->complete($id)); + break; + case 'resumable_abort': + $this->json($uploads->abort($id)); + break; + } + } + private function createFolder(string $prefix): void { $name = R2Key::safeName((string) ($_POST['folder_name'] ?? '')); @@ -555,6 +617,32 @@ final class AppController return in_array($permission, ['private', 'public-read', 'signed-only', 'archived'], true) ? $permission : 'private'; } + private function isResumableAction(string $action): bool + { + return in_array($action, [ + 'resumable_start', + 'resumable_part_url', + 'resumable_part_done', + 'resumable_complete', + 'resumable_abort', + ], true); + } + + private function resumableUploads(): ResumableUploadService + { + return new ResumableUploadService($this->config, $this->r2, $this->objects, $this->usage); + } + + /** + * @param array $payload + */ + private function json(array $payload, int $status = 200): void + { + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{"ok":false}'; + } + /** * @return list */ diff --git a/src/R2/R2Client.php b/src/R2/R2Client.php index e881f9c..5edd8f6 100644 --- a/src/R2/R2Client.php +++ b/src/R2/R2Client.php @@ -128,6 +128,64 @@ final class R2Client 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); @@ -180,7 +238,7 @@ final class R2Client /** * @param array $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 $query * @param array $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')) { diff --git a/src/Service/ResumableUploadService.php b/src/Service/ResumableUploadService.php new file mode 100644 index 0000000..207c048 --- /dev/null +++ b/src/Service/ResumableUploadService.php @@ -0,0 +1,426 @@ +storagePath = $this->config->rootPath('storage/cache/uploads'); + } + + /** + * @param array $tags + * @return array + */ + public function start( + string $prefix, + string $fileName, + int $fileSize, + string $contentType, + string $fingerprint, + array $tags = [], + string $permission = 'private', + string $notes = '', + string $source = 'dashboard' + ): array { + $this->cleanExpired(); + + $name = R2Key::safeName(basename($fileName)); + if ($name === '') { + throw new \InvalidArgumentException('File name is required.'); + } + + if ($fileSize < 0) { + throw new \InvalidArgumentException('File size is invalid.'); + } + + $prefix = R2Key::normalizePrefix($prefix); + $key = R2Key::join($prefix, $name); + $contentType = $this->contentType($contentType); + $fingerprint = $this->fingerprint($fingerprint, $source, $key, $fileSize); + + if ($fileSize === 0) { + $response = $this->r2->putObject($key, '', $contentType); + $this->objects->upsertFromR2([ + 'key' => $key, + 'size' => 0, + 'etag' => trim((string) $response->header('etag', ''), '"'), + 'content_type' => $contentType, + 'last_modified' => gmdate('c'), + 'storage_class' => $response->header('x-amz-storage-class'), + ], gmdate('c')); + $this->objects->saveLocalMeta($key, $tags, $permission, $notes); + $this->usage->record('upload', $key, 0, 1, ['files' => 1, 'resumable' => true, 'source' => $source]); + + return [ + 'ok' => true, + 'completed' => true, + 'key' => $key, + 'size' => 0, + ]; + } + + $state = $this->findReusableState($fingerprint, $source, $key, $fileSize); + if ($state === null) { + $state = [ + 'id' => bin2hex(random_bytes(16)), + 'fingerprint' => $fingerprint, + 'source' => $source, + 'key' => $key, + 'prefix' => $prefix, + 'name' => $name, + 'size' => $fileSize, + 'content_type' => $contentType, + 'upload_id' => $this->r2->createMultipartUpload($key, $contentType), + 'part_size' => $this->partSize($fileSize), + 'parts' => [], + 'tags' => $tags, + 'permission' => $permission, + 'notes' => $notes, + 'created_at' => time(), + 'updated_at' => time(), + ]; + } else { + $state['content_type'] = $contentType; + $state['tags'] = $tags; + $state['permission'] = $permission; + $state['notes'] = $notes; + $state['updated_at'] = time(); + } + + $this->writeState($state); + + return $this->publicState($state); + } + + /** + * @return array + */ + public function partUrl(string $id, int $partNumber): array + { + $state = $this->state($id); + $this->assertPartNumber($state, $partNumber); + + return [ + 'ok' => true, + 'url' => $this->r2->presignUploadPart( + (string) $state['key'], + (string) $state['upload_id'], + $partNumber, + $this->config->int('UPLOAD_PART_URL_TTL', 3600) + ), + ]; + } + + /** + * @return array + */ + public function markPart(string $id, int $partNumber, string $etag): array + { + $state = $this->state($id); + $this->assertPartNumber($state, $partNumber); + + $etag = trim($etag); + if ($etag === '') { + throw new \InvalidArgumentException('R2 did not return an ETag for that uploaded part.'); + } + + $parts = is_array($state['parts'] ?? null) ? $state['parts'] : []; + $parts[(string) $partNumber] = $etag; + $state['parts'] = $parts; + $state['updated_at'] = time(); + $this->writeState($state); + + return $this->publicState($state); + } + + /** + * @return array + */ + public function complete(string $id): array + { + $state = $this->state($id); + $parts = is_array($state['parts'] ?? null) ? $state['parts'] : []; + $totalParts = $this->totalParts($state); + + $completeParts = []; + for ($partNumber = 1; $partNumber <= $totalParts; $partNumber++) { + $etag = (string) ($parts[(string) $partNumber] ?? ''); + if ($etag === '') { + throw new \InvalidArgumentException('Upload is missing part ' . $partNumber . '.'); + } + $completeParts[] = ['PartNumber' => $partNumber, 'ETag' => $etag]; + } + + $this->r2->completeMultipartUpload((string) $state['key'], (string) $state['upload_id'], $completeParts); + + try { + $object = $this->r2->headObject((string) $state['key']); + } catch (R2Exception) { + $object = [ + 'key' => (string) $state['key'], + 'size' => (int) $state['size'], + 'etag' => null, + 'content_type' => (string) $state['content_type'], + 'last_modified' => gmdate('c'), + 'storage_class' => null, + ]; + } + + $this->objects->upsertFromR2($object, gmdate('c')); + $this->objects->saveLocalMeta( + (string) $state['key'], + is_array($state['tags'] ?? null) ? $state['tags'] : [], + (string) ($state['permission'] ?? 'private'), + (string) ($state['notes'] ?? '') + ); + $this->usage->record('upload', (string) $state['key'], (int) $state['size'], $totalParts + 3, [ + 'files' => 1, + 'parts' => $totalParts, + 'resumable' => true, + 'source' => (string) ($state['source'] ?? 'dashboard'), + ]); + $this->deleteState((string) $state['id']); + + return [ + 'ok' => true, + 'completed' => true, + 'key' => (string) $state['key'], + 'size' => (int) $state['size'], + ]; + } + + /** + * @return array + */ + public function abort(string $id): array + { + $state = $this->state($id); + try { + $this->r2->abortMultipartUpload((string) $state['key'], (string) $state['upload_id']); + } finally { + $this->deleteState((string) $state['id']); + } + + return ['ok' => true, 'aborted' => true]; + } + + /** + * @param array $state + * @return array + */ + private function publicState(array $state): array + { + return [ + 'ok' => true, + 'completed' => false, + 'id' => (string) $state['id'], + 'key' => (string) $state['key'], + 'size' => (int) $state['size'], + 'partSize' => (int) $state['part_size'], + 'totalParts' => $this->totalParts($state), + 'uploadedParts' => is_array($state['parts'] ?? null) ? $state['parts'] : [], + ]; + } + + /** + * @return array + */ + private function state(string $id): array + { + if (!preg_match('/\A[a-f0-9]{32}\z/', $id)) { + throw new \InvalidArgumentException('Upload session is invalid.'); + } + + $path = $this->statePath($id); + $state = $this->readState($path); + if ($state === null) { + throw new \InvalidArgumentException('Upload session was not found. Start the upload again.'); + } + + if ($this->isExpired($state)) { + $this->deleteState((string) $state['id']); + throw new \InvalidArgumentException('Upload session expired. Start the upload again.'); + } + + return $state; + } + + /** + * @param array $state + */ + private function assertPartNumber(array $state, int $partNumber): void + { + if ($partNumber < 1 || $partNumber > $this->totalParts($state)) { + throw new \InvalidArgumentException('Part number is invalid.'); + } + } + + /** + * @param array $state + */ + private function totalParts(array $state): int + { + return max(1, (int) ceil(((int) $state['size']) / max(1, (int) $state['part_size']))); + } + + private function partSize(int $fileSize): int + { + $configured = $this->config->int('UPLOAD_PART_SIZE', 8388608); + $requiredForPartLimit = (int) ceil($fileSize / self::MAX_PARTS); + return max(self::MIN_PART_SIZE, $configured, $requiredForPartLimit); + } + + private function contentType(string $contentType): string + { + $contentType = trim(str_replace(["\r", "\n"], '', $contentType)); + return $contentType === '' ? 'application/octet-stream' : $contentType; + } + + private function fingerprint(string $fingerprint, string $source, string $key, int $size): string + { + $fingerprint = trim($fingerprint); + if ($fingerprint === '') { + $fingerprint = $source . '|' . $key . '|' . $size; + } + + return hash('sha256', $fingerprint); + } + + /** + * @return array|null + */ + private function findReusableState(string $fingerprint, string $source, string $key, int $size): ?array + { + foreach ($this->stateFiles() as $path) { + $state = $this->readState($path); + if ($state === null || $this->isExpired($state)) { + continue; + } + + if ( + ($state['fingerprint'] ?? '') === $fingerprint + && ($state['source'] ?? '') === $source + && ($state['key'] ?? '') === $key + && (int) ($state['size'] ?? -1) === $size + ) { + return $state; + } + } + + return null; + } + + private function cleanExpired(): void + { + foreach ($this->stateFiles() as $path) { + $state = $this->readState($path); + if ($state === null) { + @unlink($path); + continue; + } + + if (!$this->isExpired($state)) { + continue; + } + + try { + $this->r2->abortMultipartUpload((string) $state['key'], (string) $state['upload_id']); + } catch (\Throwable) { + } + @unlink($path); + } + } + + /** + * @param array $state + */ + private function isExpired(array $state): bool + { + $updatedAt = (int) ($state['updated_at'] ?? $state['created_at'] ?? 0); + return $updatedAt > 0 && $updatedAt < time() - $this->config->int('UPLOAD_SESSION_TTL', 604800); + } + + /** + * @return list + */ + private function stateFiles(): array + { + if (!is_dir($this->storagePath)) { + return []; + } + + $files = glob($this->storagePath . '/*.json'); + return is_array($files) ? $files : []; + } + + /** + * @return array|null + */ + private function readState(string $path): ?array + { + if (!is_file($path)) { + return null; + } + + $json = file_get_contents($path); + if (!is_string($json)) { + return null; + } + + $state = json_decode($json, true); + return is_array($state) ? $state : null; + } + + /** + * @param array $state + */ + private function writeState(array $state): void + { + if (!is_dir($this->storagePath)) { + mkdir($this->storagePath, 0775, true); + } + + $state['updated_at'] = time(); + $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + throw new \RuntimeException('Could not serialize upload session.'); + } + + $path = $this->statePath((string) $state['id']); + $temporary = $path . '.tmp'; + file_put_contents($temporary, $json, LOCK_EX); + rename($temporary, $path); + } + + private function deleteState(string $id): void + { + if (preg_match('/\A[a-f0-9]{32}\z/', $id)) { + @unlink($this->statePath($id)); + } + } + + private function statePath(string $id): string + { + return $this->storagePath . '/' . $id . '.json'; + } +} diff --git a/views/dashboard.php b/views/dashboard.php index 6099747..2dcc268 100644 --- a/views/dashboard.php +++ b/views/dashboard.php @@ -163,7 +163,7 @@ if ($tagFilter !== '') {
-
+ @@ -192,6 +192,7 @@ if ($tagFilter !== '') { +