2026-05-31 13:02:58 +03:00
|
|
|
|
(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");
|
2026-06-02 17:41:41 +03:00
|
|
|
|
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
2026-05-31 13:02:58 +03:00
|
|
|
|
|
|
|
|
|
|
if (!form || !dropZone || !fileInput) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 15:30:53 +03:00
|
|
|
|
// 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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 13:02:58 +03:00
|
|
|
|
let latestBoxURL = "";
|
|
|
|
|
|
let selectedFiles = [];
|
2026-06-02 17:41:41 +03:00
|
|
|
|
let uploadLocked = false;
|
2026-05-31 13:02:58 +03:00
|
|
|
|
|
|
|
|
|
|
["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");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-31 13:02:58 +03:00
|
|
|
|
dropZone.addEventListener("drop", (event) => {
|
|
|
|
|
|
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
2026-06-02 17:41:41 +03:00
|
|
|
|
addSelectedFiles(event.dataTransfer.files);
|
2026-05-31 13:02:58 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
|
fileInput.addEventListener("change", () => {
|
|
|
|
|
|
addSelectedFiles(fileInput.files);
|
|
|
|
|
|
fileInput.value = "";
|
|
|
|
|
|
});
|
2026-05-31 13:02:58 +03:00
|
|
|
|
|
|
|
|
|
|
form.addEventListener("submit", async (event) => {
|
|
|
|
|
|
event.preventDefault();
|
2026-06-02 17:41:41 +03:00
|
|
|
|
if (selectedFiles.length === 0) {
|
2026-05-31 13:02:58 +03:00
|
|
|
|
updateStatus("Choose at least one file first.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const submit = form.querySelector("button[type='submit']");
|
2026-06-02 17:41:41 +03:00
|
|
|
|
const formData = uploadFormData();
|
2026-05-31 13:02:58 +03:00
|
|
|
|
renderQueue(selectedFiles, "queued");
|
|
|
|
|
|
setLoading(true, submit);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-02 17:41:41 +03:00
|
|
|
|
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
2026-05-31 13:02:58 +03:00
|
|
|
|
renderResult(payload);
|
|
|
|
|
|
form.reset();
|
2026-06-02 17:41:41 +03:00
|
|
|
|
selectedFiles = [];
|
|
|
|
|
|
fileInput.value = "";
|
|
|
|
|
|
updateSelectedState();
|
2026-05-31 13:02:58 +03:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
updateStatus(error.message || "Upload failed");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false, submit);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (copyURL) {
|
|
|
|
|
|
copyURL.addEventListener("click", () => {
|
|
|
|
|
|
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
|
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() {
|
2026-05-31 13:02:58 +03:00
|
|
|
|
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) {
|
2026-06-02 17:41:41 +03:00
|
|
|
|
uploadLocked = isLoading;
|
2026-05-31 13:02:58 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
|
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)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 13:02:58 +03:00
|
|
|
|
function renderQueue(files, status) {
|
|
|
|
|
|
if (!uploadQueue) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
uploadQueue.hidden = files.length === 0;
|
|
|
|
|
|
uploadQueue.replaceChildren();
|
2026-06-02 17:41:41 +03:00
|
|
|
|
files.forEach((file, index) => {
|
2026-05-31 13:02:58 +03:00
|
|
|
|
uploadQueue.append(createFileRow({
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
meta: window.Warpbox.formatBytes(file.size),
|
|
|
|
|
|
progress: status === "queued" ? 0 : 100,
|
|
|
|
|
|
status,
|
2026-06-02 17:41:41 +03:00
|
|
|
|
index,
|
|
|
|
|
|
removable: status === "queued",
|
2026-05-31 13:02:58 +03:00
|
|
|
|
}));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createFileRow(file) {
|
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
|
row.className = "result-item upload-file-row";
|
|
|
|
|
|
row.dataset.fileName = file.name;
|
2026-06-02 17:41:41 +03:00
|
|
|
|
row.dataset.fileIndex = file.index || 0;
|
2026-05-31 13:02:58 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-02 17:41:41 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-31 13:02:58 +03:00
|
|
|
|
|
|
|
|
|
|
row.append(body, side);
|
|
|
|
|
|
return row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 17:41:41 +03:00
|
|
|
|
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("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 13:02:58 +03:00
|
|
|
|
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})`;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-02 17:41:41 +03:00
|
|
|
|
|
|
|
|
|
|
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})`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-31 13:02:58 +03:00
|
|
|
|
})();
|