- 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:
@@ -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"
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
.env
|
||||
side-upload.json
|
||||
storage/database/*.sqlite
|
||||
storage/database/*.sqlite-*
|
||||
storage/cache/*
|
||||
!storage/cache/.gitkeep
|
||||
!storage/database/.gitkeep
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use R2Manager\R2\R2Client;
|
||||
use R2Manager\R2\R2Exception;
|
||||
use R2Manager\Repository\ObjectRepository;
|
||||
use R2Manager\Repository\UsageRepository;
|
||||
use R2Manager\Service\ResumableUploadService;
|
||||
use R2Manager\Support\Config;
|
||||
use R2Manager\Support\Database;
|
||||
use R2Manager\Support\R2Key;
|
||||
use R2Manager\Support\Tags;
|
||||
|
||||
const SIDE_UPLOAD_PASSWORD = '';
|
||||
const SIDE_UPLOAD_PASSWORD_HASH = '';
|
||||
const SIDE_UPLOAD_PREFIX = 'iphone-uploads/';
|
||||
const SIDE_UPLOAD_CONFIG_FILE = __DIR__ . '/../side-upload.json';
|
||||
|
||||
require dirname(__DIR__) . '/src/bootstrap.php';
|
||||
|
||||
$config = new Config(dirname(__DIR__));
|
||||
$database = new Database($config);
|
||||
$r2 = new R2Client($config);
|
||||
$objects = new ObjectRepository($database);
|
||||
$usage = new UsageRepository($database);
|
||||
|
||||
$side = side_upload_config($config);
|
||||
side_upload_start_session((string) $side['session_name']);
|
||||
$csrf = side_upload_csrf();
|
||||
$flash = side_upload_flash();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
|
||||
if (side_upload_is_resumable_action($action)) {
|
||||
side_upload_handle_resumable($action, $csrf, $side, new ResumableUploadService($config, $r2, $objects, $usage));
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!side_upload_validate_csrf($_POST['csrf'] ?? null)) {
|
||||
side_upload_set_flash('Your session token expired. Try again.');
|
||||
side_upload_redirect();
|
||||
}
|
||||
|
||||
if ($action === 'login') {
|
||||
if (!side_upload_enabled($side)) {
|
||||
side_upload_set_flash('Side upload is not configured yet.');
|
||||
} elseif (side_upload_verify_password((string) ($_POST['password'] ?? ''), $side)) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['side_upload_authenticated'] = true;
|
||||
side_upload_set_flash('Signed in.');
|
||||
} else {
|
||||
side_upload_set_flash('That password did not work.');
|
||||
}
|
||||
side_upload_redirect();
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
unset($_SESSION['side_upload_authenticated']);
|
||||
side_upload_set_flash('Signed out.');
|
||||
side_upload_redirect();
|
||||
}
|
||||
}
|
||||
|
||||
$authenticated = !empty($_SESSION['side_upload_authenticated']);
|
||||
$enabled = side_upload_enabled($side);
|
||||
$prefix = R2Key::normalizePrefix((string) $side['prefix']);
|
||||
$appName = (string) $side['app_name'];
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($appName) ?></title>
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="side-upload.php">
|
||||
<span class="brand-mark">R2</span>
|
||||
<span><?= h($appName) ?></span>
|
||||
</a>
|
||||
<?php if ($authenticated): ?>
|
||||
<form method="post" class="logout-form">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="action" value="logout">
|
||||
<button type="submit" class="button button-ghost">Sign out</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<?php if ($flash !== ''): ?>
|
||||
<section class="flash-stack" aria-live="polite">
|
||||
<div class="flash <?= str_contains(strtolower($flash), 'signed') ? 'flash-success' : 'flash-error' ?>"><?= h($flash) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="side-upload-shell">
|
||||
<?php if (!$enabled): ?>
|
||||
<section class="notice notice-warning">
|
||||
<strong>Side upload is disabled.</strong>
|
||||
Set <code>SIDE_UPLOAD_PASSWORD</code> or <code>SIDE_UPLOAD_PASSWORD_HASH</code> in this script, <code>.env</code>, or <code>side-upload.json</code>.
|
||||
</section>
|
||||
<?php elseif (!$authenticated): ?>
|
||||
<form method="post" class="login-panel">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="action" value="login">
|
||||
<div>
|
||||
<p class="eyebrow">Quick upload</p>
|
||||
<h1><?= h($appName) ?></h1>
|
||||
<p class="muted">Sign in with the side-upload password.</p>
|
||||
</div>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="current-password" autofocus required>
|
||||
</label>
|
||||
<button type="submit" class="button button-primary">Sign in</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<section class="panel stack">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Destination</p>
|
||||
<h1><?= h($prefix === '' ? 'Bucket root' : $prefix) ?></h1>
|
||||
</div>
|
||||
</div>
|
||||
<span class="destination-pill"><?= h($prefix === '' ? 'Bucket root' : $prefix) ?></span>
|
||||
</section>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="panel stack" data-resumable-upload data-upload-endpoint="side-upload.php">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="prefix" value="<?= h($prefix) ?>">
|
||||
<label>
|
||||
Files
|
||||
<input type="file" name="files[]" multiple required>
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<textarea name="notes" rows="3" placeholder="Optional note for the local mirror"></textarea>
|
||||
</label>
|
||||
<button type="submit" class="button button-primary">Upload</button>
|
||||
<div class="upload-progress" data-upload-progress aria-live="polite"></div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $side
|
||||
*/
|
||||
function side_upload_enabled(array $side): bool
|
||||
{
|
||||
return trim((string) ($side['password'] ?? '')) !== '' || trim((string) ($side['password_hash'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $side
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<string, mixed> $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');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"app_name": "iPhone Upload",
|
||||
"password": "replace-with-a-different-password",
|
||||
"password_hash": "",
|
||||
"prefix": "iphone-uploads/",
|
||||
"permission": "private",
|
||||
"tags": "source=iphone"
|
||||
}
|
||||
@@ -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<string, mixed> $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<array{label: string, prefix: string}>
|
||||
*/
|
||||
|
||||
+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')) {
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace R2Manager\Service;
|
||||
|
||||
use R2Manager\R2\R2Client;
|
||||
use R2Manager\R2\R2Exception;
|
||||
use R2Manager\Repository\ObjectRepository;
|
||||
use R2Manager\Repository\UsageRepository;
|
||||
use R2Manager\Support\Config;
|
||||
use R2Manager\Support\R2Key;
|
||||
|
||||
final class ResumableUploadService
|
||||
{
|
||||
private const MIN_PART_SIZE = 5242880;
|
||||
private const MAX_PARTS = 10000;
|
||||
|
||||
private string $storagePath;
|
||||
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly R2Client $r2,
|
||||
private readonly ObjectRepository $objects,
|
||||
private readonly UsageRepository $usage
|
||||
) {
|
||||
$this->storagePath = $this->config->rootPath('storage/cache/uploads');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tags
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $state
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed>|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<string, mixed> $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<string>
|
||||
*/
|
||||
private function stateFiles(): array
|
||||
{
|
||||
if (!is_dir($this->storagePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($this->storagePath . '/*.json');
|
||||
return is_array($files) ? $files : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|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<string, mixed> $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';
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -163,7 +163,7 @@ if ($tagFilter !== '') {
|
||||
</section>
|
||||
|
||||
<section class="two-column">
|
||||
<form method="post" enctype="multipart/form-data" class="panel stack">
|
||||
<form method="post" enctype="multipart/form-data" class="panel stack" data-resumable-upload data-upload-endpoint="index.php" data-upload-reload="true">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="hidden" name="action" value="upload">
|
||||
<input type="hidden" name="prefix" value="<?= htmlspecialchars($prefix) ?>">
|
||||
@@ -192,6 +192,7 @@ if ($tagFilter !== '') {
|
||||
<textarea name="notes" rows="3" placeholder="Local notes for operators"></textarea>
|
||||
</label>
|
||||
<button type="submit" class="button button-primary">Upload to R2</button>
|
||||
<div class="upload-progress" data-upload-progress aria-live="polite"></div>
|
||||
</form>
|
||||
|
||||
<form method="post" class="panel stack">
|
||||
|
||||
Reference in New Issue
Block a user