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