- 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:
Ty Clifford
2026-06-21 11:24:09 -04:00
parent 3518555d1e
commit 4b4c18f2d9
11 changed files with 1362 additions and 14 deletions
+64
View File
@@ -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,
+257
View File
@@ -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]}`;
}
+364
View File
@@ -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');
}