- 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
+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]}`;
}