feat(backend): handle processing errors and add PWA routes
- Block file downloads and previews with a 424 StatusFailedDependency if file processing failed or the box has issues. - Register routes for `/service-worker.js` and `/share-target` to support PWA features. - Update README.md with an AI usage disclosure.
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
const newUpload = document.querySelector("#new-upload");
|
||||
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
||||
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
@@ -47,6 +49,9 @@
|
||||
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) => {
|
||||
@@ -93,6 +98,12 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -108,8 +119,10 @@
|
||||
try {
|
||||
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
await clearSharedTargetPayload();
|
||||
form.reset();
|
||||
selectedFiles = [];
|
||||
sharedTargetDraft = null;
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
fileInput.value = "";
|
||||
@@ -123,6 +136,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
notifyUploadError(error);
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
@@ -136,26 +150,168 @@
|
||||
|
||||
if (newUpload) {
|
||||
newUpload.addEventListener("click", () => {
|
||||
if (sharedTargetDraft) {
|
||||
clearSharedTargetPayload().finally(() => resetFreshUploadState());
|
||||
return;
|
||||
}
|
||||
cancelRecoveredDraft().catch((error) => {
|
||||
updateStatus(error.message || "Upload draft could not be deleted");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
recoverResumableSessions();
|
||||
if (isShareTargetLaunch()) {
|
||||
loadSharedTargetFiles();
|
||||
} else {
|
||||
recoverResumableSessions();
|
||||
}
|
||||
|
||||
function addSelectedFiles(files) {
|
||||
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);
|
||||
}
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function notify(variant, message, options) {
|
||||
if (window.Warpbox && typeof window.Warpbox.notify === "function") {
|
||||
window.Warpbox.notify({ ...(options || {}), variant, message });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -175,12 +331,18 @@
|
||||
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) {
|
||||
@@ -194,7 +356,7 @@
|
||||
if (!newUpload) {
|
||||
return;
|
||||
}
|
||||
const visible = Boolean(resumeMode && recoveredDraft);
|
||||
const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft);
|
||||
newUpload.hidden = !visible;
|
||||
newUpload.style.display = visible ? "" : "none";
|
||||
}
|
||||
@@ -803,6 +965,7 @@
|
||||
selectedFiles = [];
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
sharedTargetDraft = null;
|
||||
fileInput.value = "";
|
||||
result.hidden = true;
|
||||
if (resultList) {
|
||||
@@ -913,20 +1076,22 @@
|
||||
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
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: file.name,
|
||||
meta: window.Warpbox.formatBytes(file.size),
|
||||
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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -965,6 +1130,12 @@
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user