Files
warpbox-dev/backend/static/js/40-upload.js
Daniel Legt 6a7590493c
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m58s
refactor(upload): use IncomingFile interface instead of multipart headers
Refactors the upload handler to use the `services.IncomingFile` interface instead of concrete `*multipart.FileHeader` pointers. This decouples the core upload logic from the HTTP multipart implementation, allowing for more flexible file sources.

Changes include:
- Introducing `namedMultipartFile` to adapt multipart headers to the new interface.
- Updating `createOrAppendBox`, `checkUploadPolicy`, and `totalUploadBytes` to accept `IncomingFile`.
- Renaming service calls to `CreateBoxFromIncoming` and `AppendIncomingFiles`.
2026-06-10 18:19:45 +03:00

1487 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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})`;
}
}
})();