- 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:
@@ -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]}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user