Introduce a `one-time` retention option and persist it on the manifest as `one_time_download`. One-time download boxes bypass retention expiry scheduling, force zip downloads, and reject download attempts until all files are complete to prevent partial retrievals.feat(boxstore): add one-time download retention mode Introduce a `one-time` retention option and persist it on the manifest as `one_time_download`. One-time download boxes bypass retention expiry scheduling, force zip downloads, and reject download attempts until all files are complete to prevent partial retrievals.
532 lines
17 KiB
JavaScript
532 lines
17 KiB
JavaScript
const fileInput = document.querySelector("#file-upload");
|
|
const fileCount = document.querySelector("#upload-file-count");
|
|
const fileList = document.querySelector(".upload-file-list");
|
|
const dropzone = document.querySelector(".upload-dropzone");
|
|
const uploadForm = document.querySelector(".upload-form");
|
|
const uploadStatus = document.querySelector(".upload-statusbar span:first-child");
|
|
const boxStatus = document.querySelector(".upload-statusbar span:last-child");
|
|
const uploadResult = document.querySelector(".upload-result");
|
|
const boxLink = document.querySelector("#upload-box-link");
|
|
const shareButton = document.querySelector("#upload-share-button");
|
|
const overallProgressBar = document.querySelector(".upload-overall-bar");
|
|
const overallProgressPercent = document.querySelector(".upload-overall-percent");
|
|
const retentionSelect = document.querySelector("#upload-retention");
|
|
const passwordEnabled = document.querySelector("#upload-password-enabled");
|
|
const passwordInput = document.querySelector("#upload-password");
|
|
const zipEnabled = document.querySelector("#upload-zip-enabled");
|
|
const oneTimeRetentionKey = "one-time";
|
|
|
|
let selectedFiles = [];
|
|
let statusTimer = null;
|
|
let shareURL = "";
|
|
|
|
function revokePreviewURLs() {
|
|
selectedFiles.forEach((selectedFile) => {
|
|
if (selectedFile.previewURL) {
|
|
URL.revokeObjectURL(selectedFile.previewURL);
|
|
}
|
|
});
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let size = bytes;
|
|
let unitIndex = 0;
|
|
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
|
|
if (unitIndex === 0) {
|
|
return `${size} ${units[unitIndex]}`;
|
|
}
|
|
|
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function iconForFile(file) {
|
|
const filename = file.name || "";
|
|
const mimeType = file.type || "";
|
|
const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
|
|
|
|
if (extension === ".exe") {
|
|
return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png";
|
|
}
|
|
|
|
if (mimeType.startsWith("image/")) {
|
|
return "/static/img/sprites/bitmap.png";
|
|
}
|
|
|
|
if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) {
|
|
return "/static/img/icons/netshow_notransm-1.png";
|
|
}
|
|
|
|
if (mimeType.startsWith("text/") || extension === ".md") {
|
|
return "/static/img/sprites/notepad_file-1.png";
|
|
}
|
|
|
|
if (
|
|
mimeType.includes("zip") ||
|
|
mimeType.includes("compressed") ||
|
|
[".rar", ".7z", ".tar", ".gz"].includes(extension)
|
|
) {
|
|
return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png";
|
|
}
|
|
|
|
if ([".ttf", ".otf", ".woff", ".woff2"].includes(extension)) {
|
|
return "/static/img/sprites/font.png";
|
|
}
|
|
|
|
if (extension === ".pdf") {
|
|
return "/static/img/sprites/journal.png";
|
|
}
|
|
|
|
if ([".html", ".css", ".js"].includes(extension)) {
|
|
return "/static/img/sprites/frame_web-0.png";
|
|
}
|
|
|
|
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png";
|
|
}
|
|
|
|
function updateStatus(message) {
|
|
if (uploadStatus) {
|
|
uploadStatus.textContent = message;
|
|
}
|
|
}
|
|
|
|
function stopStatusAnimation() {
|
|
if (statusTimer) {
|
|
clearInterval(statusTimer);
|
|
statusTimer = null;
|
|
}
|
|
}
|
|
|
|
function animateUploadStatus(getPrefix) {
|
|
let dotCount = 0;
|
|
stopStatusAnimation();
|
|
|
|
statusTimer = setInterval(() => {
|
|
dotCount = (dotCount % 3) + 1;
|
|
updateStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`);
|
|
}, 350);
|
|
}
|
|
|
|
function setBoxStatus(message) {
|
|
if (boxStatus) {
|
|
boxStatus.textContent = message;
|
|
boxStatus.title = message;
|
|
}
|
|
}
|
|
|
|
function isOneTimeDownloadSelected() {
|
|
return retentionSelect && retentionSelect.value === oneTimeRetentionKey;
|
|
}
|
|
|
|
function updateZipOptionForRetention() {
|
|
if (!zipEnabled) {
|
|
return;
|
|
}
|
|
|
|
if (isOneTimeDownloadSelected()) {
|
|
zipEnabled.checked = true;
|
|
zipEnabled.disabled = true;
|
|
return;
|
|
}
|
|
|
|
zipEnabled.disabled = false;
|
|
}
|
|
|
|
function setBoxLink(path) {
|
|
shareURL = path ? new URL(path, window.location.origin).toString() : "";
|
|
|
|
if (uploadResult) {
|
|
uploadResult.classList.toggle("is-hidden", !shareURL);
|
|
}
|
|
|
|
if (boxLink) {
|
|
boxLink.href = shareURL || "#";
|
|
boxLink.textContent = shareURL || "Waiting for upload";
|
|
boxLink.title = shareURL;
|
|
boxLink.classList.toggle("is-empty", !shareURL);
|
|
boxLink.setAttribute("aria-disabled", shareURL ? "false" : "true");
|
|
}
|
|
|
|
if (shareButton) {
|
|
shareButton.disabled = !shareURL;
|
|
}
|
|
}
|
|
|
|
function setOverallProgress(percent) {
|
|
const clampedPercent = Math.max(0, Math.min(100, percent));
|
|
const displayPercent = `${Math.round(clampedPercent)}%`;
|
|
|
|
if (overallProgressBar) {
|
|
overallProgressBar.style.width = displayPercent;
|
|
}
|
|
|
|
if (overallProgressPercent) {
|
|
overallProgressPercent.textContent = displayPercent;
|
|
}
|
|
}
|
|
|
|
function updateOverallProgress() {
|
|
const totalBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.file.size, 0);
|
|
const loadedBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.loaded, 0);
|
|
const uploadedCount = selectedFiles.filter((selectedFile) => selectedFile.uploaded).length;
|
|
const percent = totalBytes > 0 ? (loadedBytes / totalBytes) * 100 : 0;
|
|
setOverallProgress(percent >= 100 && uploadedCount < selectedFiles.length ? 99 : percent);
|
|
}
|
|
|
|
function updateFileCount() {
|
|
if (fileCount) {
|
|
fileCount.textContent = `${selectedFiles.length} ${selectedFiles.length === 1 ? "file" : "files"}`;
|
|
}
|
|
}
|
|
|
|
function setRowProgress(row, percent) {
|
|
const progressBar = row.querySelector(".upload-progress-bar");
|
|
if (progressBar) {
|
|
progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
}
|
|
}
|
|
|
|
function createFileRow(selectedFile) {
|
|
const row = document.createElement("div");
|
|
row.className = "upload-file-row";
|
|
row.classList.toggle("has-thumbnail", Boolean(selectedFile.previewURL));
|
|
|
|
const icon = document.createElement("img");
|
|
icon.className = "upload-file-icon";
|
|
icon.src = selectedFile.previewURL || iconForFile(selectedFile.file);
|
|
icon.alt = "";
|
|
icon.setAttribute("aria-hidden", "true");
|
|
|
|
const name = document.createElement("span");
|
|
name.className = "upload-file-name";
|
|
name.textContent = selectedFile.file.name;
|
|
name.title = selectedFile.file.name;
|
|
|
|
const size = document.createElement("span");
|
|
size.className = "upload-file-size";
|
|
size.textContent = formatBytes(selectedFile.file.size);
|
|
|
|
const progress = document.createElement("span");
|
|
progress.className = "upload-progress";
|
|
progress.setAttribute("aria-hidden", "true");
|
|
|
|
const progressBar = document.createElement("span");
|
|
progressBar.className = "upload-progress-bar";
|
|
progress.append(progressBar);
|
|
|
|
row.append(icon, name, size, progress);
|
|
selectedFile.row = row;
|
|
return row;
|
|
}
|
|
|
|
function updateSelectedFiles(files) {
|
|
revokePreviewURLs();
|
|
selectedFiles = Array.from(files || []).map((file) => ({
|
|
file,
|
|
previewURL: file.type.startsWith("image/") ? URL.createObjectURL(file) : "",
|
|
loaded: 0,
|
|
row: null,
|
|
uploaded: false,
|
|
failed: false,
|
|
}));
|
|
|
|
updateFileCount();
|
|
|
|
if (!fileList) {
|
|
return;
|
|
}
|
|
|
|
fileList.replaceChildren();
|
|
|
|
if (!selectedFiles.length) {
|
|
const emptyState = document.createElement("p");
|
|
emptyState.className = "upload-empty-state";
|
|
emptyState.textContent = "No files selected";
|
|
fileList.append(emptyState);
|
|
updateStatus("Ready");
|
|
setBoxStatus("WarpBox");
|
|
setBoxLink("");
|
|
setOverallProgress(0);
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
selectedFiles.forEach((selectedFile) => {
|
|
fragment.append(createFileRow(selectedFile));
|
|
});
|
|
|
|
fileList.append(fragment);
|
|
updateStatus("Files selected");
|
|
setBoxStatus("WarpBox");
|
|
setBoxLink("");
|
|
setOverallProgress(0);
|
|
}
|
|
|
|
async function createBox() {
|
|
const response = await fetch("/box", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
retention_key: retentionSelect ? retentionSelect.value : "10s",
|
|
password: passwordEnabled && passwordEnabled.checked && passwordInput ? passwordInput.value : "",
|
|
allow_zip: isOneTimeDownloadSelected() || !zipEnabled || zipEnabled.checked,
|
|
files: selectedFiles.map((selectedFile) => ({
|
|
name: selectedFile.file.name,
|
|
size: selectedFile.file.size,
|
|
})),
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Could not create upload box");
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async function markFileStatus(selectedFile, status) {
|
|
if (!selectedFile.boxFile) {
|
|
return;
|
|
}
|
|
|
|
await fetch(`/box/${selectedFile.boxID}/files/${selectedFile.boxFile.id}/status`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
}
|
|
|
|
function uploadFile(boxID, selectedFile, onComplete) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
const formData = new FormData();
|
|
formData.append("file", selectedFile.file);
|
|
|
|
xhr.open("POST", selectedFile.boxFile.upload_path);
|
|
|
|
xhr.upload.addEventListener("loadstart", () => {
|
|
selectedFile.loaded = 0;
|
|
selectedFile.row.classList.add("is-uploading");
|
|
selectedFile.row.title = "Loading";
|
|
updateOverallProgress();
|
|
setRowProgress(selectedFile.row, 2);
|
|
});
|
|
|
|
xhr.upload.addEventListener("progress", (event) => {
|
|
if (!event.lengthComputable) {
|
|
return;
|
|
}
|
|
|
|
selectedFile.loaded = Math.min(event.loaded, selectedFile.file.size);
|
|
updateOverallProgress();
|
|
const percent = (event.loaded / event.total) * 100;
|
|
if (percent >= 100) {
|
|
selectedFile.row.classList.add("is-processing");
|
|
selectedFile.row.title = "Loading";
|
|
setRowProgress(selectedFile.row, 99);
|
|
return;
|
|
}
|
|
|
|
setRowProgress(selectedFile.row, percent);
|
|
});
|
|
|
|
xhr.addEventListener("load", () => {
|
|
if (xhr.status < 200 || xhr.status >= 300) {
|
|
selectedFile.failed = true;
|
|
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
selectedFile.row.classList.add("is-failed");
|
|
selectedFile.row.title = "Failed to upload";
|
|
markFileStatus(selectedFile, "failed");
|
|
reject(new Error("Upload failed"));
|
|
return;
|
|
}
|
|
|
|
selectedFile.uploaded = true;
|
|
selectedFile.loaded = selectedFile.file.size;
|
|
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
selectedFile.row.classList.add("is-uploaded");
|
|
selectedFile.row.title = "Uploaded";
|
|
updateOverallProgress();
|
|
setRowProgress(selectedFile.row, 100);
|
|
onComplete();
|
|
resolve();
|
|
});
|
|
|
|
xhr.addEventListener("error", () => {
|
|
selectedFile.failed = true;
|
|
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
selectedFile.row.classList.add("is-failed");
|
|
selectedFile.row.title = "Failed to upload";
|
|
markFileStatus(selectedFile, "failed");
|
|
reject(new Error("Upload failed"));
|
|
});
|
|
|
|
xhr.addEventListener("abort", () => {
|
|
selectedFile.failed = true;
|
|
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
selectedFile.row.classList.add("is-failed");
|
|
selectedFile.row.title = "Failed to upload";
|
|
markFileStatus(selectedFile, "failed");
|
|
reject(new Error("Upload cancelled"));
|
|
});
|
|
|
|
markFileStatus(selectedFile, "uploading");
|
|
xhr.send(formData);
|
|
});
|
|
}
|
|
|
|
if (fileInput) {
|
|
fileInput.addEventListener("change", () => {
|
|
stopStatusAnimation();
|
|
updateSelectedFiles(fileInput.files);
|
|
});
|
|
}
|
|
|
|
if (passwordEnabled && passwordInput) {
|
|
passwordEnabled.addEventListener("change", () => {
|
|
passwordInput.disabled = !passwordEnabled.checked;
|
|
if (!passwordEnabled.checked) {
|
|
passwordInput.value = "";
|
|
return;
|
|
}
|
|
|
|
passwordInput.focus();
|
|
});
|
|
}
|
|
|
|
if (retentionSelect) {
|
|
updateZipOptionForRetention();
|
|
retentionSelect.addEventListener("change", updateZipOptionForRetention);
|
|
}
|
|
|
|
if (fileInput && dropzone) {
|
|
dropzone.addEventListener("dragover", (event) => {
|
|
event.preventDefault();
|
|
dropzone.classList.add("is-dragging");
|
|
});
|
|
|
|
dropzone.addEventListener("dragleave", () => {
|
|
dropzone.classList.remove("is-dragging");
|
|
});
|
|
|
|
dropzone.addEventListener("drop", (event) => {
|
|
event.preventDefault();
|
|
dropzone.classList.remove("is-dragging");
|
|
|
|
if (!event.dataTransfer.files.length) {
|
|
return;
|
|
}
|
|
|
|
fileInput.files = event.dataTransfer.files;
|
|
stopStatusAnimation();
|
|
updateSelectedFiles(fileInput.files);
|
|
});
|
|
}
|
|
|
|
if (uploadForm) {
|
|
uploadForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
|
|
if (!selectedFiles.length) {
|
|
updateStatus("Choose files first");
|
|
return;
|
|
}
|
|
|
|
if (passwordEnabled && passwordEnabled.checked && passwordInput && !passwordInput.value.trim()) {
|
|
updateStatus("Enter password");
|
|
passwordInput.focus();
|
|
return;
|
|
}
|
|
|
|
let completedCount = 0;
|
|
const totalCount = selectedFiles.length;
|
|
const statusPrefix = () => `${completedCount}/${totalCount}`;
|
|
|
|
selectedFiles.forEach((selectedFile) => {
|
|
selectedFile.uploaded = false;
|
|
selectedFile.failed = false;
|
|
selectedFile.loaded = 0;
|
|
selectedFile.row.classList.remove("is-uploaded", "is-failed", "is-uploading", "is-processing");
|
|
selectedFile.row.title = "";
|
|
setRowProgress(selectedFile.row, 0);
|
|
});
|
|
|
|
setBoxLink("");
|
|
setOverallProgress(0);
|
|
updateStatus(`${statusPrefix()} Uploading.`);
|
|
animateUploadStatus(statusPrefix);
|
|
|
|
try {
|
|
const box = await createBox();
|
|
setBoxStatus(box.box_url);
|
|
setBoxLink(box.box_url);
|
|
|
|
selectedFiles.forEach((selectedFile, index) => {
|
|
selectedFile.boxID = box.box_id;
|
|
selectedFile.boxFile = box.files[index];
|
|
const icon = selectedFile.row.querySelector(".upload-file-icon");
|
|
if (icon && selectedFile.boxFile.thumbnail_path) {
|
|
selectedFile.row.classList.add("has-thumbnail");
|
|
icon.src = selectedFile.boxFile.thumbnail_path;
|
|
} else if (icon && selectedFile.boxFile.icon_path && !selectedFile.previewURL) {
|
|
icon.src = selectedFile.boxFile.icon_path;
|
|
}
|
|
});
|
|
|
|
await Promise.allSettled(selectedFiles.map((selectedFile) => {
|
|
return uploadFile(box.box_id, selectedFile, () => {
|
|
completedCount += 1;
|
|
});
|
|
}));
|
|
|
|
stopStatusAnimation();
|
|
const failedCount = selectedFiles.filter((selectedFile) => selectedFile.failed).length;
|
|
if (failedCount > 0) {
|
|
updateStatus(`${completedCount}/${totalCount} Uploaded, ${failedCount} failed`);
|
|
return;
|
|
}
|
|
|
|
setOverallProgress(100);
|
|
updateStatus(`${completedCount}/${totalCount} Uploaded`);
|
|
} catch (error) {
|
|
stopStatusAnimation();
|
|
setBoxLink("");
|
|
updateStatus("Upload failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
if (shareButton) {
|
|
shareButton.addEventListener("click", async () => {
|
|
if (!shareURL) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (navigator.share) {
|
|
await navigator.share({
|
|
title: "WarpBox download",
|
|
text: "Download these files from WarpBox",
|
|
url: shareURL,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await navigator.clipboard.writeText(shareURL);
|
|
updateStatus("Link copied");
|
|
} catch (error) {
|
|
updateStatus("Share cancelled");
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener("beforeunload", revokePreviewURLs);
|