4b4c18f2d9
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).
272 lines
7.9 KiB
JavaScript
272 lines
7.9 KiB
JavaScript
document.addEventListener('click', (event) => {
|
|
const button = event.target.closest('[data-confirm]');
|
|
if (button && !window.confirm(button.dataset.confirm)) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('focus', (event) => {
|
|
const input = event.target.closest('[data-copyable]');
|
|
if (input) {
|
|
input.select();
|
|
}
|
|
}, 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]}`;
|
|
}
|