(function () { const form = document.querySelector("#upload-form"); const dropZone = document.querySelector(".drop-zone"); const fileInput = document.querySelector("#file-input"); const fileSummary = document.querySelector("#file-summary"); const progress = document.querySelector("#upload-progress"); const uploadStatus = document.querySelector("#upload-status"); const result = document.querySelector("#upload-result"); const resultMeta = document.querySelector("#result-meta"); const resultList = document.querySelector("#result-list"); const uploadQueue = document.querySelector("#upload-queue"); const totalProgressBar = document.querySelector("#total-progress-bar"); const copyURL = document.querySelector("#copy-url"); const openBox = document.querySelector("#open-box"); const manageLink = document.querySelector("#manage-link"); const newUpload = document.querySelector("#new-upload"); const folderPicker = document.querySelector("[data-folder-picker]"); const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; const SHARE_CACHE = "warpbox-share-target-v1"; const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest"; const CELLULAR_WARNING_THRESHOLD_BYTES = 200 * 1024 * 1024; if (!form || !dropZone || !fileInput) { return; } // Remember the last-chosen expiry across uploads (per browser). const expirySelect = form.querySelector("[data-expiry-select]"); if (expirySelect) { const EXPIRY_KEY = "warpbox-expiry"; let saved = null; try { saved = localStorage.getItem(EXPIRY_KEY); } catch (e) { saved = null; } if (saved && expirySelect.querySelector('option[value="' + saved + '"]')) { expirySelect.value = saved; } expirySelect.addEventListener("change", () => { try { localStorage.setItem(EXPIRY_KEY, expirySelect.value); } catch (e) { /* ignore persistence failures */ } }); } let latestBoxURL = ""; let selectedFiles = []; let uploadLocked = false; let recoveredDraft = null; let resumeMode = false; let sharedTargetDraft = null; const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10); const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit"); ["dragenter", "dragover"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { event.preventDefault(); dropZone.classList.add("is-dragging"); }); }); ["dragleave", "drop"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { event.preventDefault(); dropZone.classList.remove("is-dragging"); }); }); document.addEventListener("dragover", (event) => { if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) { event.preventDefault(); } }); document.addEventListener("drop", (event) => { if (!hasTransferFiles(event.dataTransfer)) { return; } event.preventDefault(); if (!dropZone.contains(event.target)) { addDroppedFiles(event.dataTransfer); } }); dropZone.addEventListener("drop", (event) => { if (hasTransferFiles(event.dataTransfer)) { addDroppedFiles(event.dataTransfer); } }); fileInput.addEventListener("change", () => { addSelectedFiles(fileInput.files); fileInput.value = ""; }); document.addEventListener("paste", (event) => { if (!event.clipboardData || !event.clipboardData.files || event.clipboardData.files.length === 0) { return; } if (isTextEditingTarget(event.target)) { return; } event.preventDefault(); addSelectedFiles(event.clipboardData.files, { source: "pasted" }); }); if (folderPicker && typeof window.showDirectoryPicker === "function") { folderPicker.hidden = false; folderPicker.addEventListener("click", async () => { if (uploadLocked) { return; } try { updateStatus("Reading folder..."); const directory = await window.showDirectoryPicker(); const files = await filesFromDirectoryHandle(directory, directory.name || ""); addSelectedFiles(files, { source: "folder" }); } catch (error) { if (!error || error.name !== "AbortError") { updateStatus("Folder could not be read."); } } }); } form.addEventListener("submit", async (event) => { event.preventDefault(); if (selectedFiles.length === 0) { updateStatus("Choose at least one file first."); notify("warning", "Choose at least one file first.", { title: "No files selected", }); return; } if (!validateSelectedFilesWithinLimit(selectedFiles)) { return; } if (isSlowOrMeteredConnection() && totalSelectedBytes(selectedFiles) >= CELLULAR_WARNING_THRESHOLD_BYTES) { const proceed = await confirmCellularUpload(selectedFiles); if (!proceed) { return; } } const submit = form.querySelector("button[type='submit']"); const formData = uploadFormData(); await maybeRequestUploadNotificationPermission(selectedFiles); if (resumeMode && recoveredDraft) { renderResumeQueue(recoveredDraft.session, selectedFiles); } else { renderQueue(selectedFiles, "queued"); } setLoading(true, submit); try { const payload = await uploadResumable(form.action, formData, selectedFiles); renderResult(payload); showUploadNotification("Warpbox upload complete", `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} uploaded.`, payload.boxUrl); await clearSharedTargetPayload(); form.reset(); selectedFiles = []; sharedTargetDraft = null; resumeMode = false; recoveredDraft = null; fileInput.value = ""; if (uploadQueue) { uploadQueue.hidden = true; uploadQueue.replaceChildren(); } updateNewUploadVisibility(); if (fileSummary) { fileSummary.textContent = "Upload complete."; } } catch (error) { updateStatus(error.message || "Upload failed"); notifyUploadError(error); showUploadNotification("Warpbox upload failed", error.message || "Upload failed"); } finally { setLoading(false, submit); } }); if (copyURL) { copyURL.addEventListener("click", () => { window.Warpbox.copyText(latestBoxURL, copyURL, "Copied"); }); } if (newUpload) { newUpload.addEventListener("click", () => { if (sharedTargetDraft) { clearSharedTargetPayload().finally(() => resetFreshUploadState()); return; } cancelRecoveredDraft().catch((error) => { updateStatus(error.message || "Upload draft could not be deleted"); }); }); } if (isShareTargetLaunch()) { loadSharedTargetFiles(); } else { recoverResumableSessions(); } function addSelectedFiles(files, options) { if (uploadLocked) { return; } const rejected = []; Array.from(files || []).forEach((file) => { if (fileExceedsUploadLimit(file)) { rejected.push(file); return; } if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) { selectedFiles.push(file); } }); if (rejected.length > 0) { notifyRejectedFiles(rejected); } if (options && options.source === "pasted" && files && files.length > 0) { updateStatus(`${files.length} pasted file${files.length === 1 ? "" : "s"} ready.`); } if (options && options.source === "folder" && files && files.length > 0) { updateStatus(`${files.length} folder file${files.length === 1 ? "" : "s"} ready.`); } updateSelectedState(); } async function addDroppedFiles(dataTransfer) { if (uploadLocked) { return; } const files = await filesFromDataTransfer(dataTransfer); addSelectedFiles(files, { source: hasDirectoryItems(dataTransfer) ? "folder" : "dropped" }); } async function filesFromDataTransfer(dataTransfer) { const items = Array.from(dataTransfer.items || []); const entries = items .map((item) => typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null) .filter(Boolean); if (entries.length === 0) { return Array.from(dataTransfer.files || []); } const nested = await Promise.all(entries.map((entry) => filesFromEntry(entry, ""))); return nested.flat(); } function hasDirectoryItems(dataTransfer) { return Array.from(dataTransfer.items || []).some((item) => { const entry = typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null; return entry && entry.isDirectory; }); } function hasTransferFiles(dataTransfer) { if (!dataTransfer) { return false; } if (dataTransfer.files && dataTransfer.files.length > 0) { return true; } return Array.from(dataTransfer.items || []).some((item) => item.kind === "file"); } function filesFromEntry(entry, parentPath) { if (!entry) { return Promise.resolve([]); } const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name; if (entry.isFile) { return new Promise((resolve) => { entry.file((file) => resolve([withRelativePath(file, relativePath)]), () => resolve([])); }); } if (!entry.isDirectory) { return Promise.resolve([]); } const reader = entry.createReader(); const children = []; return new Promise((resolve) => { const readBatch = () => { reader.readEntries(async (entries) => { if (!entries.length) { const nested = await Promise.all(children.map((child) => filesFromEntry(child, relativePath))); resolve(nested.flat()); return; } children.push(...entries); readBatch(); }, () => resolve([])); }; readBatch(); }); } async function filesFromDirectoryHandle(directory, parentPath) { const files = []; for await (const [name, handle] of directory.entries()) { const relativePath = parentPath ? `${parentPath}/${name}` : name; if (handle.kind === "file") { const file = await handle.getFile(); files.push(withRelativePath(file, relativePath)); } else if (handle.kind === "directory") { files.push(...await filesFromDirectoryHandle(handle, relativePath)); } } return files; } function withRelativePath(file, relativePath) { if (!file || !relativePath) { return file; } try { Object.defineProperty(file, "warpboxRelativePath", { value: normalizeRelativePath(relativePath), configurable: true, }); } catch (error) { file.warpboxRelativePath = normalizeRelativePath(relativePath); } return file; } function normalizeRelativePath(value) { return String(value || "") .replace(/\\/g, "/") .split("/") .filter((part) => part && part !== "." && part !== "..") .join("/"); } function uploadName(file) { return normalizeRelativePath(file && (file.warpboxRelativePath || file.webkitRelativePath || file.name)) || (file && file.name) || "file"; } function isTextEditingTarget(target) { if (!target) { return false; } const tag = (target.tagName || "").toLowerCase(); return tag === "input" || tag === "textarea" || target.isContentEditable; } function fileExceedsUploadLimit(file) { return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes; } function validateSelectedFilesWithinLimit(files) { const rejected = Array.from(files || []).filter(fileExceedsUploadLimit); if (rejected.length === 0) { return true; } selectedFiles = selectedFiles.filter((file) => !fileExceedsUploadLimit(file)); notifyRejectedFiles(rejected); updateSelectedState(); return false; } function notifyRejectedFiles(files) { const names = files.slice(0, 3).map((file) => `"${file.name}" (${window.Warpbox.formatBytes(file.size)})`).join(", "); const extra = files.length > 3 ? `, and ${files.length - 3} more` : ""; const message = `${names}${extra} ${files.length === 1 ? "is" : "are"} over the ${maxUploadLabel} upload limit.`; updateStatus(message); notify("error", message, { title: "Upload limit exceeded", duration: 9000, }); } function notifyUploadError(error) { const message = error && error.message ? error.message : "Upload failed"; const lower = message.toLowerCase(); const isLimit = lower.includes("limit") || lower.includes("quota") || lower.includes("too large") || lower.includes("exceeds"); notify("error", message, { title: isLimit ? "Upload limit reached" : "Upload failed", duration: isLimit ? 9000 : 7200, }); } async function maybeRequestUploadNotificationPermission(files) { if (!("Notification" in window) || Notification.permission !== "default" || totalSelectedBytes(files) < CELLULAR_WARNING_THRESHOLD_BYTES) { return; } try { await Notification.requestPermission(); } catch (error) { /* notification permission is optional */ } } async function showUploadNotification(title, body, url) { if (!("Notification" in window) || Notification.permission !== "granted") { return; } if (document.visibilityState === "visible") { return; } const options = { body, icon: "/static/android-chrome-192x192.png", badge: "/static/favicon-32x32.png", data: { url: window.Warpbox.absoluteURL(url || "/") }, }; try { const registration = navigator.serviceWorker ? await navigator.serviceWorker.ready : null; if (registration && registration.showNotification) { await registration.showNotification(title, options); return; } } catch (error) { /* fall through to page notification */ } try { const notification = new Notification(title, options); notification.onclick = () => { window.focus(); if (url) { window.location.href = window.Warpbox.absoluteURL(url); } notification.close(); }; } catch (error) { /* notifications are best-effort */ } } function notify(variant, message, options) { if (window.Warpbox && typeof window.Warpbox.notify === "function") { window.Warpbox.notify({ ...(options || {}), variant, message }); } } function isSlowOrMeteredConnection() { const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (!connection) { return false; } if (connection.saveData === true) { return true; } return ["slow-2g", "2g", "3g"].includes(connection.effectiveType); } function totalSelectedBytes(files) { return files.reduce((sum, file) => sum + file.size, 0); } function confirmCellularUpload(files) { const list = document.createElement("div"); list.className = "dialog-file-list"; files.forEach((file) => { const icon = document.createElement("span"); icon.className = "svg-icon svg-icon-document dialog-file-icon"; icon.setAttribute("aria-hidden", "true"); const name = document.createElement("span"); name.className = "dialog-file-name"; name.textContent = file.name; name.title = file.name; const size = document.createElement("span"); size.className = "dialog-file-size"; size.textContent = window.Warpbox.formatBytes(file.size); const row = document.createElement("div"); row.className = "dialog-file-row"; row.append(icon, name, size); list.append(row); }); const totalLabel = window.Warpbox.formatBytes(totalSelectedBytes(files)); const message = `You're on a slow or metered connection. You're about to upload ${files.length} file${files.length === 1 ? "" : "s"} (${totalLabel} total) — this could take a while or use up your data plan.`; return window.Warpbox.confirmDialog(message, { title: "Slow connection detected", variant: "warning", body: list, confirmLabel: "Upload anyway", cancelLabel: "Cancel", }); } function isShareTargetLaunch() { const params = new URLSearchParams(window.location.search || ""); return params.has("share-target"); } async function loadSharedTargetFiles() { if (!("caches" in window) || typeof File === "undefined") { updateStatus("Shared files could not be loaded in this browser."); recoverResumableSessions(); return; } updateStatus("Loading shared files..."); try { const cache = await caches.open(SHARE_CACHE); const metadataResponse = await cache.match(SHARE_LATEST_KEY); if (!metadataResponse) { updateStatus(new URLSearchParams(window.location.search).get("share-target") === "unsupported" ? "Install Warpbox as an app to share files into it from your device." : "No shared files were found."); recoverResumableSessions(); return; } const metadata = await metadataResponse.json(); if (metadata.error) { updateStatus(metadata.error); recoverResumableSessions(); return; } const files = []; for (const item of metadata.files || []) { if (!item.key) { continue; } const response = await cache.match(item.key); if (!response) { continue; } const blob = await response.blob(); files.push(new File([blob], item.name || "shared-file", { type: item.type || blob.type || "application/octet-stream", lastModified: item.lastModified || Date.now(), })); } sharedTargetDraft = metadata; selectedFiles = files; resumeMode = false; recoveredDraft = null; validateSelectedFilesWithinLimit(selectedFiles); if (selectedFiles.length > 0) { renderQueue(selectedFiles, "queued", { shared: true }); updateStatus("Shared files ready."); } else { updateStatus("No files were included in this share."); } updateSelectedState(); } catch (error) { updateStatus(error.message || "Shared files could not be loaded."); recoverResumableSessions(); } } async function clearSharedTargetPayload() { const draft = sharedTargetDraft; sharedTargetDraft = null; if (!draft || !("caches" in window)) { sharedTargetDraft = null; return; } try { const cache = await caches.open(SHARE_CACHE); for (const item of draft.files || []) { if (item.key) { await cache.delete(item.key); } } if (draft.id) { await cache.delete("/__warpbox_share_target__/meta/" + encodeURIComponent(draft.id)); } await cache.delete(SHARE_LATEST_KEY); } catch (error) { /* ignore cache cleanup failures */ } } function removeSelectedFile(index) { if (uploadLocked) { return; } selectedFiles.splice(index, 1); updateSelectedState(); } function updateSelectedState() { const count = selectedFiles.length || 0; const title = dropZone.querySelector(".drop-title"); if (title) { title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`; } if (fileSummary) { if (resumeMode && recoveredDraft) { fileSummary.textContent = count === 0 ? "Reselect missing files to resume, or add extra files to this upload." : `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`; } else if (sharedTargetDraft) { fileSummary.textContent = count === 0 ? "No shared files were received." : `${count} shared file${count === 1 ? "" : "s"} ready. Review options, then upload.`; } else { fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; } } if (resumeMode && recoveredDraft) { renderResumeQueue(recoveredDraft.session, selectedFiles); } else if (sharedTargetDraft && count > 0) { renderQueue(selectedFiles, "queued", { shared: true }); } else if (count > 0) { renderQueue(selectedFiles, "queued"); } else if (uploadQueue) { uploadQueue.hidden = true; uploadQueue.replaceChildren(); } updateNewUploadVisibility(); } function updateNewUploadVisibility() { if (!newUpload) { return; } const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft); newUpload.hidden = !visible; newUpload.style.display = visible ? "" : "none"; } function setLoading(isLoading, submit) { uploadLocked = isLoading; if (progress) { progress.hidden = !isLoading; } if (submit) { submit.disabled = isLoading; submit.textContent = isLoading ? "Uploading..." : "Upload files"; } if (newUpload) { newUpload.disabled = isLoading; } updateStatus(isLoading ? "Transferring files..." : ""); setTotalProgress(isLoading ? 0 : 100); } function updateStatus(message) { if (uploadStatus) { uploadStatus.textContent = message; } } function updateUploadProgress(percent, bytesPerSecond) { const clamped = Math.max(0, Math.min(100, Math.round(percent || 0))); const rate = formatTransferRate(bytesPerSecond); updateStatus(rate ? `${clamped}% · ${rate}` : `${clamped}%`); } function createTransferRateTracker(initialBytes) { const startedAt = performance.now(); const baseline = Math.max(0, initialBytes || 0); let lastRate = 0; return function track(currentBytes) { const elapsedSeconds = (performance.now() - startedAt) / 1000; const transferred = Math.max(0, (currentBytes || 0) - baseline); if (elapsedSeconds < 0.25 || transferred <= 0) { return lastRate; } lastRate = transferred / elapsedSeconds; return lastRate; }; } function formatTransferRate(bytesPerSecond) { if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) { return ""; } const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"]; let value = bytesPerSecond * 8; let unit = 0; while (value >= 1000 && unit < units.length - 1) { value /= 1000; unit += 1; } return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`; } function renderResult(payload) { if (!result || !resultList || !resultMeta || !openBox) { return; } latestBoxURL = window.Warpbox.absoluteURL(payload.boxUrl); result.hidden = false; openBox.href = latestBoxURL; resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`; if (manageLink) { const anchor = manageLink.querySelector("a"); manageLink.hidden = !payload.manageUrl; if (anchor && payload.manageUrl) { anchor.href = window.Warpbox.absoluteURL(payload.manageUrl); } } resultList.replaceChildren(); payload.files.forEach((file) => { resultList.append(createFileRow({ name: file.name, meta: `${file.size} · ${window.Warpbox.absoluteURL(file.url)}`, progress: 100, status: "complete", })); }); result.scrollIntoView({ behavior: "smooth", block: "start" }); } function uploadWithProgress(url, formData, files) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); const rateTracker = createTransferRateTracker(0); request.open("POST", url); request.setRequestHeader("Accept", "application/json"); request.upload.addEventListener("progress", (event) => { const rate = rateTracker(event.loaded || 0); if (!event.lengthComputable) { updateStatus(rate > 0 ? `Uploading · ${formatTransferRate(rate)}` : "Uploading..."); return; } const percent = Math.round((event.loaded / event.total) * 100); updateUploadProgress(percent, rate); setTotalProgress(percent); setFileProgress(files, percent); }); request.addEventListener("load", () => { let payload = {}; try { payload = JSON.parse(request.responseText || "{}"); } catch (error) { reject(new Error("Upload response could not be read")); return; } if (request.status < 200 || request.status >= 300) { reject(new Error(payload.error || "Upload failed")); return; } setTotalProgress(100); setFileProgress(files, 100); resolve(payload); }); request.addEventListener("error", () => reject(new Error("Network error during upload"))); request.addEventListener("abort", () => reject(new Error("Upload aborted"))); request.send(formData); }); } async function uploadResumable(fallbackUrl, formData, files) { if (!window.fetch || typeof Blob === "undefined") { return uploadWithProgress(fallbackUrl, formData, files); } updateStatus("Fingerprinting files..."); const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file))); const createPayload = { files: files.map((file, index) => ({ name: uploadName(file), size: file.size, contentType: file.type || "application/octet-stream", fingerprint: fingerprints[index], })), expiresMinutes: parseInt(formData.get("expires_minutes") || "0", 10) || 0, maxDownloads: parseInt(formData.get("max_downloads") || "0", 10) || 0, password: formData.get("password") || "", obfuscateMetadata: formData.get("obfuscate_metadata") === "on", collectionId: formData.get("collection_id") || "", }; const persistable = !createPayload.password; let session = null; if (persistable && resumeMode && recoveredDraft) { session = await fetchResumableStatus(recoveredDraft.session.sessionId, recoveredDraft.session.resumeToken); session.resumeToken = recoveredDraft.session.resumeToken; } else if (persistable) { session = await findResumableSession(createPayload); } if (session) { validateResumeSelection(session, createPayload); session = await addMissingResumableFiles(session, createPayload); if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) { recoveredDraft.session = session; } if (persistable) { saveResumableSession(session, createPayload); } } if (!session || session.status !== "uploading") { try { session = await createResumableSession(createPayload); } catch (error) { if ((error.message || "").toLowerCase().includes("resumable uploads are disabled")) { return uploadWithProgress(fallbackUrl, formData, files); } throw error; } if (persistable) { saveResumableSession(session, createPayload); } } const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index])); if (sessionFiles.some((file) => !file)) { throw new Error("Upload session could not match the selected files"); } updateStatus("Uploading..."); const totalBytes = files.reduce((sum, file) => sum + file.size, 0); const completedByFile = new Array(files.length).fill(0); sessionFiles.forEach((sessionFile, index) => { completedByFile[index] = uploadedBytesForSessionFile(sessionFile, session.chunkSize); setSingleFileProgress(index, files[index], percentForBytes(completedByFile[index], files[index].size)); }); const initiallyUploadedBytes = completedByFile.reduce((sum, bytes) => sum + bytes, 0); const rateTracker = createTransferRateTracker(initiallyUploadedBytes); setTotalProgress(percentForBytes(initiallyUploadedBytes, totalBytes)); for (let fileIndex = 0; fileIndex < files.length; fileIndex++) { const file = files[fileIndex]; const sessionFile = sessionFiles[fileIndex]; const uploaded = new Set(sessionFile.uploadedChunks || []); for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) { if (uploaded.has(chunkIndex)) { continue; } const start = chunkIndex * session.chunkSize; const end = Math.min(file.size, start + session.chunkSize); await uploadChunkWithRetry(session, sessionFile, chunkIndex, file.slice(start, end), (loaded) => { const currentTotal = completedByFile.reduce((sum, bytes) => sum + bytes, 0) + loaded; const percent = percentForBytes(currentTotal, totalBytes); const rate = rateTracker(currentTotal); setTotalProgress(percent); setSingleFileProgress(fileIndex, file, percentForBytes(completedByFile[fileIndex] + loaded, file.size)); updateUploadProgress(percent, rate); }); completedByFile[fileIndex] += end - start; uploaded.add(chunkIndex); sessionFile.uploadedChunks = Array.from(uploaded).sort((a, b) => a - b); if (persistable) { saveResumableSession(session, createPayload); } } setSingleFileProgress(fileIndex, file, 100); } updateStatus("Finalizing upload..."); const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken); const wasResumeMode = resumeMode; if (persistable) { removeResumableSession(session.sessionId); } if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) { resumeMode = false; recoveredDraft = null; } setTotalProgress(100); if (!wasResumeMode) { setFileProgress(files, 100); } return resultPayload; } async function createResumableSession(payload) { const response = await fetch("/api/v1/uploads/resumable", { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }); return readUploadJSON(response, "Upload session could not be created"); } async function fetchResumableStatus(sessionID, resumeToken) { const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, { headers: resumableHeaders(resumeToken), }); return readUploadJSON(response, "Upload session could not be resumed"); } async function addResumableFiles(sessionID, resumeToken, files) { const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files`, { method: "POST", headers: { ...resumableHeaders(resumeToken), "Accept": "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ files }), }); return readUploadJSON(response, "Upload session files could not be added"); } function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`); request.setRequestHeader("Accept", "application/json"); request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || ""); request.upload.addEventListener("progress", (event) => { if (event.lengthComputable && onProgress) { onProgress(event.loaded); } }); request.addEventListener("load", () => { if (request.status < 200 || request.status >= 300) { let payload = {}; try { payload = JSON.parse(request.responseText || "{}"); } catch (error) { payload = {}; } reject(new Error(payload.error || "Chunk upload failed")); return; } resolve(); }); request.addEventListener("error", () => reject(new Error("Network error during chunk upload"))); request.addEventListener("abort", () => reject(new Error("Chunk upload aborted"))); request.send(chunk); }); } async function uploadChunkWithRetry(session, sessionFile, chunkIndex, chunk, onProgress) { const delays = [1000, 2000, 5000, 10000, 20000]; let lastError = null; for (let attempt = 0; attempt <= delays.length; attempt++) { try { return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress); } catch (error) { lastError = error; if (attempt >= delays.length) { break; } const seconds = Math.round(delays[attempt] / 1000); updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`); await wait(delays[attempt]); } } throw lastError || new Error("Chunk upload failed"); } async function completeResumableSession(sessionID, resumeToken) { const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/complete`, { method: "POST", headers: resumableHeaders(resumeToken), }); return readUploadJSON(response, "Upload could not be completed"); } async function cancelResumableSession(sessionID, resumeToken) { const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, { method: "DELETE", headers: resumableHeaders(resumeToken), }); if (!response.ok && response.status !== 404) { await readUploadJSON(response, "Upload draft could not be deleted"); } } function resumableHeaders(resumeToken) { return { "Accept": "application/json", "X-Warpbox-Resume-Token": resumeToken || "", }; } function wait(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } async function readUploadJSON(response, fallback) { let payload = {}; try { payload = await response.json(); } catch (error) { payload = {}; } if (!response.ok) { throw new Error(payload.error || fallback); } return payload; } async function findResumableSession(payload) { const records = loadResumableSessions(); const optionKey = resumableOptionKey(payload); const selectedKeys = new Set(payload.files.map((file) => resumableFileKey(file))); for (const record of records) { if (record.optionKey !== optionKey) { continue; } if (!record.files || !record.files.some((file) => selectedKeys.has(resumableFileKey(file)))) { continue; } const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null); if (!session || session.status !== "uploading") { removeResumableSession(record.sessionId); continue; } session.resumeToken = record.resumeToken; const sessionKeys = new Set(session.files.map((file) => resumableFileKey(file))); const selectedContainsSessionFile = Array.from(sessionKeys).some((key) => selectedKeys.has(key)); if (selectedContainsSessionFile) { return session; } } return null; } async function addMissingResumableFiles(session, payload) { const existing = new Set(session.files.map((file) => resumableFileKey(file))); const missing = payload.files.filter((file) => !existing.has(resumableFileKey(file))); if (missing.length === 0) { return session; } const updated = await addResumableFiles(session.sessionId, session.resumeToken, missing); updated.resumeToken = session.resumeToken; return updated; } function validateResumeSelection(session, payload) { if (!resumeMode || !recoveredDraft || session.sessionId !== recoveredDraft.session.sessionId) { return; } const existingByNameSize = new Map(); (session.files || []).forEach((file) => { existingByNameSize.set(`${file.name}:${file.size}`, resumableFileKey(file)); }); for (const file of payload.files || []) { const expectedKey = existingByNameSize.get(`${file.name}:${file.size}`); if (expectedKey && expectedKey !== resumableFileKey(file)) { throw new Error(`"${file.name}" does not match the pending upload. Select the exact original file.`); } } } function matchSessionFile(session, file) { const key = resumableFileKey(file); return session.files.find((sessionFile) => resumableFileKey(sessionFile) === key) || null; } function resumableOptionKey(payload) { return [ payload.expiresMinutes, payload.maxDownloads, payload.obfuscateMetadata ? "1" : "0", payload.collectionId || "", ].join(":"); } function resumableFileKey(file) { return [file.name, file.size, file.fingerprint || ""].join(":"); } function loadResumableSessions() { try { const value = localStorage.getItem(RESUMABLE_SESSIONS_KEY); const records = value ? JSON.parse(value) : []; return Array.isArray(records) ? records : []; } catch (error) { return []; } } function saveResumableSession(session, payload) { try { const records = loadResumableSessions().filter((record) => record.sessionId !== session.sessionId); records.push({ sessionId: session.sessionId, resumeToken: session.resumeToken || "", optionKey: resumableOptionKey(payload), options: { expiresMinutes: payload.expiresMinutes, maxDownloads: payload.maxDownloads, obfuscateMetadata: !!payload.obfuscateMetadata, collectionId: payload.collectionId || "", }, files: session.files.map((file) => ({ name: file.name, size: file.size, contentType: file.contentType || "application/octet-stream", fingerprint: file.fingerprint || "", uploadedChunks: file.uploadedChunks || [], chunkCount: file.chunkCount || 0, })), updatedAt: new Date().toISOString(), }); localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records.slice(-25))); } catch (error) { /* ignore persistence failures */ } } async function recoverResumableSessions() { const records = loadResumableSessions() .filter((record) => record.sessionId && record.resumeToken) .sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime()); if (records.length === 0) { return; } for (const record of records) { const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null); if (!session || session.status !== "uploading") { removeResumableSession(record.sessionId); continue; } session.resumeToken = record.resumeToken; recoveredDraft = { session, record }; selectedFiles = []; renderRecoveredQueue([{ session, record }]); updateRecoveredSummary(session); showRecoveryModal(recoveredDraft); return; } } function updateRecoveredSummary(session) { updateStatus("Unfinished upload found. Choose how to continue."); if (fileSummary) { const totalFiles = (session.files || []).length; const completedFiles = completedSessionFiles(session).length; fileSummary.textContent = `Recovered ${totalFiles} pending file${totalFiles === 1 ? "" : "s"}; ${completedFiles} fully uploaded.`; } } function removeResumableSession(sessionID) { try { const records = loadResumableSessions().filter((record) => record.sessionId !== sessionID); localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records)); } catch (error) { /* ignore persistence failures */ } } function completedSessionFiles(session) { return (session.files || []).filter((file) => (file.uploadedChunks || []).length >= file.chunkCount); } function showRecoveryModal(draft) { const old = document.querySelector(".upload-recovery-overlay"); if (old) { old.remove(); } const completeCount = completedSessionFiles(draft.session).length; const totalCount = (draft.session.files || []).length; const overlay = document.createElement("div"); overlay.className = "upload-recovery-overlay"; overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); overlay.setAttribute("aria-labelledby", "upload-recovery-title"); const modal = document.createElement("div"); modal.className = "upload-recovery-modal card"; const content = document.createElement("div"); content.className = "card-content"; const title = document.createElement("h2"); title.id = "upload-recovery-title"; title.textContent = "Unfinished upload found"; const copy = document.createElement("p"); copy.textContent = `Warpbox found a private draft with ${totalCount} file${totalCount === 1 ? "" : "s"}. ${completeCount} file${completeCount === 1 ? " is" : "s are"} already fully uploaded.`; const actions = document.createElement("div"); actions.className = "upload-recovery-actions"; const startOver = document.createElement("button"); startOver.type = "button"; startOver.className = "button button-danger"; startOver.textContent = "New Upload"; startOver.addEventListener("click", async () => { startOver.disabled = true; try { await cancelRecoveredDraft(); overlay.remove(); } catch (error) { startOver.disabled = false; updateStatus(error.message || "Upload draft could not be deleted"); } }); const resume = document.createElement("button"); resume.type = "button"; resume.className = "button button-primary"; resume.textContent = "Resume"; resume.addEventListener("click", () => { resumeRecoveredDraft(); overlay.remove(); }); actions.append(startOver, resume); content.append(title, copy, actions); modal.append(content); overlay.append(modal); document.body.append(overlay); } async function cancelRecoveredDraft() { if (!recoveredDraft) { resetFreshUploadState(); return; } const draft = recoveredDraft; updateStatus("Deleting unfinished upload..."); await cancelResumableSession(draft.session.sessionId, draft.session.resumeToken); removeResumableSession(draft.session.sessionId); resetFreshUploadState(); } function resumeRecoveredDraft() { if (!recoveredDraft) { return; } resumeMode = true; selectedFiles = []; renderResumeQueue(recoveredDraft.session, selectedFiles); updateSelectedState(); updateNewUploadVisibility(); updateStatus("Drop or reselect missing files to continue. Extra files will be added to this upload."); } function resetFreshUploadState() { selectedFiles = []; resumeMode = false; recoveredDraft = null; sharedTargetDraft = null; fileInput.value = ""; result.hidden = true; if (resultList) { resultList.replaceChildren(); } setTotalProgress(0); updateStatus(""); updateSelectedState(); } function uploadedBytesForSessionFile(file, chunkSize) { return (file.uploadedChunks || []).reduce((sum, index) => { const start = index * chunkSize; const end = Math.min(file.size, start + chunkSize); return sum + Math.max(0, end - start); }, 0); } function renderRecoveredQueue(items) { if (!uploadQueue) { return; } const rows = []; items.forEach(({ session }) => { (session.files || []).forEach((file) => { const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize); const complete = (file.uploadedChunks || []).length >= file.chunkCount; rows.push({ name: file.name, size: file.size, uploadedBytes, meta: complete ? `${window.Warpbox.formatBytes(file.size)} · uploaded` : `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · Drop/reselect this file to continue`, progress: percentForBytes(uploadedBytes, file.size), status: complete ? "complete" : "waiting", readonly: true, }); }); }); uploadQueue.hidden = rows.length === 0; uploadQueue.replaceChildren(); rows.forEach((row) => uploadQueue.append(createFileRow(row))); const totalBytes = rows.reduce((sum, row) => sum + (row.size || 0), 0); if (totalBytes > 0) { setTotalProgress(percentForBytes(rows.reduce((sum, row) => sum + (row.uploadedBytes || 0), 0), totalBytes)); } else if (rows.length > 0) { const completed = rows.filter((row) => row.status === "complete").length; setTotalProgress(percentForBytes(completed, rows.length)); } } function renderResumeQueue(session, localFiles) { if (!uploadQueue) { return; } const rows = []; const localByNameSize = new Map(); (localFiles || []).forEach((file, index) => { localByNameSize.set(`${uploadName(file)}:${file.size}`, { file, index }); }); const usedLocalIndexes = new Set(); (session.files || []).forEach((file) => { const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize); const complete = (file.uploadedChunks || []).length >= file.chunkCount; const localMatch = localByNameSize.get(`${file.name}:${file.size}`) || null; if (localMatch) { usedLocalIndexes.add(localMatch.index); } rows.push({ name: uploadName(file), size: file.size, uploadedBytes, meta: complete ? `${window.Warpbox.formatBytes(file.size)} · uploaded` : localMatch ? `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · ready to resume` : `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · waiting for local file`, progress: percentForBytes(uploadedBytes, file.size), status: complete ? "complete" : localMatch ? "queued" : "waiting", readonly: !localMatch, index: localMatch ? localMatch.index : undefined, removable: Boolean(localMatch && !complete), }); }); (localFiles || []).forEach((file, index) => { if (usedLocalIndexes.has(index)) { return; } rows.push({ name: uploadName(file), meta: `${window.Warpbox.formatBytes(file.size)} · new file`, progress: 0, status: "queued", index, removable: true, }); }); uploadQueue.hidden = rows.length === 0; uploadQueue.replaceChildren(); rows.forEach((row) => uploadQueue.append(createFileRow(row))); } function percentForBytes(bytes, total) { if (!total) { return 100; } return Math.max(0, Math.min(100, Math.round((bytes / total) * 100))); } function renderQueue(files, status, options) { if (!uploadQueue) { return; } const shared = Boolean(options && options.shared); uploadQueue.hidden = files.length === 0; uploadQueue.replaceChildren(); files.forEach((file, index) => { uploadQueue.append(createFileRow({ name: uploadName(file), meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size), progress: status === "queued" ? 0 : 100, status, index, removable: status === "queued", shared, })); }); } function createFileRow(file) { const row = document.createElement("div"); row.className = `result-item upload-file-row upload-file-${file.status || "queued"}`; row.dataset.fileName = file.name; if (typeof file.index === "number") { row.dataset.fileIndex = file.index; } const body = document.createElement("span"); const name = document.createElement("strong"); name.className = "file-name"; name.textContent = file.name; name.title = file.name; const meta = document.createElement("code"); meta.textContent = file.meta; body.append(name, meta); const side = document.createElement("div"); side.className = "file-progress-side"; const percent = document.createElement("span"); percent.className = "file-progress-percent"; percent.textContent = `${file.progress}%`; const bar = document.createElement("div"); bar.className = "progress file-progress"; const fill = document.createElement("span"); fill.style.transform = `scaleX(${file.progress / 100})`; bar.append(fill); side.append(percent, bar); if (file.status === "waiting") { const badge = document.createElement("small"); badge.className = "upload-file-state"; badge.textContent = "Needs local file"; side.append(badge); } if (file.shared) { const badge = document.createElement("small"); badge.className = "upload-file-state upload-file-state-shared"; badge.textContent = "Shared from device"; side.append(badge); } if (file.removable) { const remove = document.createElement("button"); remove.className = "upload-file-remove"; remove.type = "button"; remove.setAttribute("aria-label", `Remove ${file.name}`); remove.textContent = "×"; remove.addEventListener("click", () => removeSelectedFile(file.index || 0)); side.append(remove); } row.append(body, side); return row; } function uploadFormData() { const formData = new FormData(form); formData.delete("file"); formData.delete("file_path"); selectedFiles.forEach((file) => { formData.append("file", file, uploadName(file)); formData.append("file_path", uploadName(file)); }); return formData; } function fileIdentity(file) { return [uploadName(file), file.size, file.lastModified || 0].join(":"); } async function fileFingerprint(file) { if (!window.crypto || !window.crypto.subtle || !file.slice || typeof TextEncoder === "undefined") { return fileIdentity(file); } const sampleSize = Math.min(file.size, 1024 * 1024); const sample = await file.slice(0, sampleSize).arrayBuffer(); const metadata = new TextEncoder().encode([uploadName(file), file.size, file.lastModified || 0, sampleSize].join(":")); const combined = new Uint8Array(metadata.byteLength + sample.byteLength); combined.set(metadata, 0); combined.set(new Uint8Array(sample), metadata.byteLength); const digest = await window.crypto.subtle.digest("SHA-256", combined); return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } function setTotalProgress(percent) { if (totalProgressBar) { totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`; } } function setFileProgress(files, totalPercent) { if (!uploadQueue) { return; } const count = files.length || 1; const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count; uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => { const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100))); const percent = row.querySelector(".file-progress-percent"); const fill = row.querySelector(".file-progress span"); if (percent) { percent.textContent = `${progress}%`; } if (fill) { fill.style.transform = `scaleX(${progress / 100})`; } }); } function setSingleFileProgress(index, file, progress) { if (!uploadQueue) { return; } const row = uploadQueue.querySelector(`.upload-file-row[data-file-index="${index}"]`); if (!row) { return; } const percent = row.querySelector(".file-progress-percent"); const fill = row.querySelector(".file-progress span"); const normalized = Math.max(0, Math.min(100, progress)); if (percent) { percent.textContent = `${normalized}%`; } if (fill) { fill.style.transform = `scaleX(${normalized / 100})`; } } })();