(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 RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; 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; ["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 (!event.dataTransfer || !event.dataTransfer.files.length) { return; } event.preventDefault(); if (!dropZone.contains(event.target)) { addSelectedFiles(event.dataTransfer.files); } }); dropZone.addEventListener("drop", (event) => { if (event.dataTransfer && event.dataTransfer.files.length > 0) { addSelectedFiles(event.dataTransfer.files); } }); fileInput.addEventListener("change", () => { addSelectedFiles(fileInput.files); fileInput.value = ""; }); form.addEventListener("submit", async (event) => { event.preventDefault(); if (selectedFiles.length === 0) { updateStatus("Choose at least one file first."); return; } const submit = form.querySelector("button[type='submit']"); const formData = uploadFormData(); renderQueue(selectedFiles, "queued"); setLoading(true, submit); try { const payload = await uploadResumable(form.action, formData, selectedFiles); renderResult(payload); form.reset(); selectedFiles = []; fileInput.value = ""; updateSelectedState(); } catch (error) { updateStatus(error.message || "Upload failed"); } finally { setLoading(false, submit); } }); if (copyURL) { copyURL.addEventListener("click", () => { window.Warpbox.copyText(latestBoxURL, copyURL, "Copied"); }); } function addSelectedFiles(files) { if (uploadLocked) { return; } Array.from(files || []).forEach((file) => { if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) { selectedFiles.push(file); } }); updateSelectedState(); } 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) { fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; } if (count > 0) { renderQueue(selectedFiles, "queued"); } else if (uploadQueue) { uploadQueue.hidden = true; uploadQueue.replaceChildren(); } } function setLoading(isLoading, submit) { uploadLocked = isLoading; if (progress) { progress.hidden = !isLoading; } if (submit) { submit.disabled = isLoading; submit.textContent = isLoading ? "Uploading..." : "Upload files"; } updateStatus(isLoading ? "Transferring files..." : ""); setTotalProgress(isLoading ? 0 : 100); } function updateStatus(message) { if (uploadStatus) { uploadStatus.textContent = message; } } function renderResult(payload) { if (!result || !resultList || !resultMeta || !openBox) { return; } latestBoxURL = payload.boxUrl; result.hidden = false; openBox.href = payload.boxUrl; 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 = payload.manageUrl; } } resultList.replaceChildren(); payload.files.forEach((file) => { resultList.append(createFileRow({ name: file.name, meta: `${file.size} · ${file.url}`, progress: 100, status: "complete", })); }); } function uploadWithProgress(url, formData, files) { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.open("POST", url); request.setRequestHeader("Accept", "application/json"); request.upload.addEventListener("progress", (event) => { if (!event.lengthComputable) { updateStatus("Uploading..."); return; } const percent = Math.round((event.loaded / event.total) * 100); updateStatus(`${percent}%`); 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: file.name, 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 = persistable ? await findResumableSession(createPayload) : null; if (session) { session = await addMissingResumableFiles(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)); }); setTotalProgress(percentForBytes(completedByFile.reduce((sum, bytes) => sum + bytes, 0), 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 uploadChunk(session.sessionId, sessionFile.id, chunkIndex, file.slice(start, end), (loaded) => { const currentTotal = completedByFile.reduce((sum, bytes) => sum + bytes, 0) + loaded; setTotalProgress(percentForBytes(currentTotal, totalBytes)); setSingleFileProgress(fileIndex, file, percentForBytes(completedByFile[fileIndex] + loaded, file.size)); updateStatus(`${percentForBytes(currentTotal, totalBytes)}%`); }); 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); } const resultPayload = await completeResumableSession(session.sessionId); if (persistable) { removeResumableSession(session.sessionId); } setTotalProgress(100); 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) { const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, { headers: { "Accept": "application/json" }, }); return readUploadJSON(response, "Upload session could not be resumed"); } async function addResumableFiles(sessionID, files) { const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files`, { method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ files }), }); return readUploadJSON(response, "Upload session files could not be added"); } function uploadChunk(sessionID, 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.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 completeResumableSession(sessionID) { const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/complete`, { method: "POST", headers: { "Accept": "application/json" }, }); return readUploadJSON(response, "Upload could not be completed"); } 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).catch(() => null); if (!session || session.status !== "uploading") { removeResumableSession(record.sessionId); continue; } const sessionKeys = new Set(session.files.map((file) => resumableFileKey(file))); const sessionHasOnlySelectedFiles = session.files.every((file) => selectedKeys.has(resumableFileKey(file))); const selectedContainsSessionFile = Array.from(sessionKeys).some((key) => selectedKeys.has(key)); if (sessionHasOnlySelectedFiles && 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; } return addResumableFiles(session.sessionId, missing); } 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, optionKey: resumableOptionKey(payload), files: session.files.map((file) => ({ name: file.name, size: file.size, fingerprint: file.fingerprint || "", })), updatedAt: new Date().toISOString(), }); localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records.slice(-25))); } catch (error) { /* ignore persistence failures */ } } 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 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 percentForBytes(bytes, total) { if (!total) { return 100; } return Math.max(0, Math.min(100, Math.round((bytes / total) * 100))); } function renderQueue(files, status) { if (!uploadQueue) { return; } uploadQueue.hidden = files.length === 0; uploadQueue.replaceChildren(); files.forEach((file, index) => { uploadQueue.append(createFileRow({ name: file.name, meta: window.Warpbox.formatBytes(file.size), progress: status === "queued" ? 0 : 100, status, index, removable: status === "queued", })); }); } function createFileRow(file) { const row = document.createElement("div"); row.className = "result-item upload-file-row"; row.dataset.fileName = file.name; row.dataset.fileIndex = file.index || 0; 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.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"); selectedFiles.forEach((file) => { formData.append("file", file, file.name); }); return formData; } function fileIdentity(file) { return [file.name, 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([file.name, 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})`; } } })();