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:
2026-06-08 11:53:37 +03:00
parent dbfdacc396
commit d11aec96e5
26 changed files with 1186 additions and 35 deletions

View File

@@ -0,0 +1,173 @@
.warpbox-popups {
position: fixed;
z-index: 120;
inset-block-start: calc(1rem + env(safe-area-inset-top));
inset-inline-end: calc(1rem + env(safe-area-inset-right));
width: min(26rem, calc(100vw - 2rem));
display: grid;
gap: 0.75rem;
pointer-events: none;
}
.warpbox-popup {
pointer-events: auto;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.25rem);
background: color-mix(in srgb, var(--card) 96%, transparent);
color: var(--card-foreground);
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(-0.55rem);
transition: opacity 160ms ease, transform 160ms ease;
overflow: hidden;
}
.warpbox-popup.is-visible {
opacity: 1;
transform: translateY(0);
}
.warpbox-popup-chrome {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: start;
padding: 0.95rem;
}
.warpbox-popup-icon {
width: 1.6rem;
height: 1.6rem;
display: grid;
place-items: center;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
font-weight: 800;
line-height: 1;
}
.warpbox-popup-warning .warpbox-popup-icon {
background: color-mix(in srgb, var(--primary) 26%, transparent);
color: var(--primary-hover);
}
.warpbox-popup-error .warpbox-popup-icon {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.warpbox-popup-title {
display: block;
margin: 0 0 0.18rem;
font-size: 0.92rem;
line-height: 1.2;
}
.warpbox-popup-message {
margin: 0;
color: var(--muted-foreground);
font-size: 0.84rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.warpbox-popup-close {
min-height: 1.8rem;
width: 1.8rem;
padding: 0;
border-color: var(--border);
color: var(--muted-foreground);
background: var(--surface-1);
font-size: 1rem;
line-height: 1;
}
.warpbox-popup-close:hover {
color: var(--foreground);
background: var(--surface-1-hover);
}
.warpbox-popup-actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding: 0 0.95rem 0.95rem;
}
:root[data-theme="retro"] .warpbox-popups {
inset-block-start: 2.65rem;
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
}
:root[data-theme="retro"] .warpbox-popup {
border: 1px solid #000000;
background: #c0c0c0;
color: #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 3px 3px 0 rgba(0, 0, 0, 0.45);
}
:root[data-theme="retro"] .warpbox-popup::before {
content: "Warpbox";
display: block;
margin: 0.18rem 0.18rem 0;
padding: 0.22rem 0.35rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 0.78rem;
font-weight: 700;
}
:root[data-theme="retro"] .warpbox-popup-error::before {
content: "Warpbox - Error";
}
:root[data-theme="retro"] .warpbox-popup-warning::before {
content: "Warpbox - Warning";
}
:root[data-theme="retro"] .warpbox-popup-info::before {
content: "Warpbox - Info";
}
:root[data-theme="retro"] .warpbox-popup-chrome {
padding: 0.8rem;
}
:root[data-theme="retro"] .warpbox-popup-icon {
border: 1px solid #000000;
background: #ffffff;
color: #000078;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .warpbox-popup-warning .warpbox-popup-icon {
color: #9a5b00;
}
:root[data-theme="retro"] .warpbox-popup-error .warpbox-popup-icon {
color: #c00000;
}
:root[data-theme="retro"] .warpbox-popup-message {
color: #000000;
}
:root[data-theme="retro"] .warpbox-popup-close {
width: 1.45rem;
height: 1.25rem;
min-height: 1.25rem;
background: #c0c0c0;
color: #000000;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
font-size: 0.78rem;
font-weight: 700;
}
@media (max-width: 640px) {
.warpbox-popups {
inset-inline: 1rem;
width: auto;
}
}

View File

@@ -56,6 +56,10 @@
display: none !important;
}
.install-pwa-button[hidden] {
display: none !important;
}
.hero-copy {
text-align: center;
}
@@ -395,6 +399,10 @@ button {
text-align: right;
}
.upload-file-state-shared {
color: var(--primary);
}
.upload-recovery-overlay {
position: fixed;
inset: 0;

View File

@@ -698,6 +698,12 @@
text-decoration: none;
}
.button.is-disabled {
opacity: .62;
cursor: not-allowed;
pointer-events: none;
}
.upload-processing-alert {
margin: 1rem 0;
padding: .85rem 1rem;
@@ -707,6 +713,11 @@
color: var(--foreground);
}
.upload-processing-alert-error {
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
background: color-mix(in srgb, var(--danger) 14%, transparent);
}
.thumb-link {
flex: 0 0 4.75rem;
width: 4.75rem;
@@ -870,6 +881,24 @@
cursor: wait;
}
.file-card.is-failed {
border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
background: color-mix(in srgb, var(--danger) 8%, var(--background));
}
.file-card.is-failed .file-open {
cursor: not-allowed;
}
.file-error {
display: block;
max-width: 100%;
margin-top: 0.18rem;
color: var(--danger);
white-space: normal;
overflow-wrap: anywhere;
}
.file-reaction-dock {
position: static;
z-index: 2;

View File

@@ -0,0 +1,43 @@
(function () {
let installPrompt = null;
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js").catch(() => {
/* Service workers are progressive enhancement here. */
});
});
}
window.addEventListener("beforeinstallprompt", (event) => {
const button = document.querySelector("[data-install-pwa]");
if (!button) {
return;
}
event.preventDefault();
installPrompt = event;
button.hidden = false;
button.addEventListener("click", async () => {
if (!installPrompt) {
return;
}
button.disabled = true;
try {
await installPrompt.prompt();
await installPrompt.userChoice;
} finally {
installPrompt = null;
button.hidden = true;
button.disabled = false;
}
}, { once: true });
});
window.addEventListener("appinstalled", () => {
const button = document.querySelector("[data-install-pwa]");
if (button) {
button.hidden = true;
}
installPrompt = null;
});
})();

View File

@@ -0,0 +1,174 @@
(function () {
const DEFAULT_DURATION = 6200;
const VARIANTS = ["info", "warning", "error"];
const GENERIC_ERROR_MESSAGE = "Something went wrong on this page. Please try again in a moment.";
window.Warpbox = window.Warpbox || {};
let lastGlobalErrorAt = 0;
function ensureRegion() {
let region = document.querySelector("[data-warpbox-popups]");
if (region) {
return region;
}
region = document.createElement("div");
region.className = "warpbox-popups";
region.setAttribute("data-warpbox-popups", "");
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "false");
document.body.append(region);
return region;
}
function normalizeOptions(options, message) {
if (typeof options === "string") {
options = { message: options };
} else {
options = options || {};
}
if (message) {
options.message = message;
}
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
return {
variant,
title: options.title || defaultTitle(variant),
message: options.message || "",
duration: Number.isFinite(options.duration) ? options.duration : DEFAULT_DURATION,
actions: Array.isArray(options.actions) ? options.actions : [],
};
}
function defaultTitle(variant) {
if (variant === "error") {
return "Error";
}
if (variant === "warning") {
return "Warning";
}
return "Info";
}
function notify(options, message) {
const config = normalizeOptions(options, message);
const region = ensureRegion();
const popup = document.createElement("section");
popup.className = "warpbox-popup warpbox-popup-" + config.variant;
popup.setAttribute("role", config.variant === "error" ? "alert" : "status");
const chrome = document.createElement("div");
chrome.className = "warpbox-popup-chrome";
const icon = document.createElement("span");
icon.className = "warpbox-popup-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
const body = document.createElement("div");
body.className = "warpbox-popup-body";
const title = document.createElement("strong");
title.className = "warpbox-popup-title";
title.textContent = config.title;
const text = document.createElement("p");
text.className = "warpbox-popup-message";
text.textContent = config.message;
body.append(title, text);
const close = document.createElement("button");
close.type = "button";
close.className = "warpbox-popup-close";
close.setAttribute("aria-label", "Dismiss notification");
close.textContent = "x";
close.addEventListener("click", () => dismiss(popup));
chrome.append(icon, body, close);
popup.append(chrome);
if (config.actions.length > 0) {
const actions = document.createElement("div");
actions.className = "warpbox-popup-actions";
config.actions.forEach((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "button " + (action.kind === "primary" ? "button-primary" : "button-outline");
button.textContent = action.label || "Action";
button.addEventListener("click", () => {
if (typeof action.onClick === "function") {
action.onClick();
}
if (action.dismiss !== false) {
dismiss(popup);
}
});
actions.append(button);
});
popup.append(actions);
}
region.append(popup);
window.requestAnimationFrame(() => popup.classList.add("is-visible"));
let timer = null;
if (config.duration > 0) {
timer = window.setTimeout(() => dismiss(popup), config.duration);
}
return {
element: popup,
close: function closePopup() {
if (timer) {
window.clearTimeout(timer);
}
dismiss(popup);
},
};
}
function dismiss(popup) {
if (!popup || popup.dataset.closing === "true") {
return;
}
popup.dataset.closing = "true";
popup.classList.remove("is-visible");
window.setTimeout(() => popup.remove(), 180);
}
window.Warpbox.notify = notify;
window.Warpbox.info = function info(message, options) {
return notify({ ...(options || {}), variant: "info", message });
};
window.Warpbox.warning = function warning(message, options) {
return notify({ ...(options || {}), variant: "warning", message });
};
window.Warpbox.error = function error(message, options) {
return notify({ ...(options || {}), variant: "error", message });
};
function showGlobalError() {
const now = Date.now();
if (now - lastGlobalErrorAt < 2500) {
return;
}
lastGlobalErrorAt = now;
notify({
variant: "error",
title: "Page error",
message: GENERIC_ERROR_MESSAGE,
duration: 9000,
});
}
window.addEventListener("error", function (event) {
if (event && event.target && event.target !== window) {
return;
}
showGlobalError();
});
window.addEventListener("unhandledrejection", function () {
showGlobalError();
});
})();

View File

@@ -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";

View File

@@ -0,0 +1,110 @@
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method === "POST" && url.origin === self.location.origin && url.pathname === "/share-target") {
event.respondWith(handleShareTarget(event.request));
}
});
const SHARE_CACHE = "warpbox-share-target-v1";
const SHARE_PREFIX = "/__warpbox_share_target__/";
const LATEST_KEY = SHARE_PREFIX + "latest";
async function handleShareTarget(request) {
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
try {
const formData = await request.formData();
const files = collectSharedFiles(formData);
const cache = await caches.open(SHARE_CACHE);
const metadata = {
id,
title: stringValue(formData.get("title")),
text: stringValue(formData.get("text")),
url: stringValue(formData.get("url")),
createdAt: new Date().toISOString(),
files: [],
};
await deletePreviousShare(cache);
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const key = SHARE_PREFIX + "file/" + encodeURIComponent(id) + "/" + index;
metadata.files.push({
key,
name: file.name || "shared-file",
type: file.type || "application/octet-stream",
size: file.size || 0,
lastModified: file.lastModified || Date.now(),
});
await cache.put(key, new Response(file, {
headers: {
"Content-Type": file.type || "application/octet-stream",
"Cache-Control": "no-store",
},
}));
}
await cache.put(LATEST_KEY, jsonResponse(metadata));
await cache.put(SHARE_PREFIX + "meta/" + encodeURIComponent(id), jsonResponse(metadata));
} catch (error) {
await storeShareError(id, error);
}
return Response.redirect("/?share-target=1&share-id=" + encodeURIComponent(id), 303);
}
function collectSharedFiles(formData) {
const files = [];
["files", "file", "sharex"].forEach((name) => {
formData.getAll(name).forEach((value) => {
if (value instanceof File && value.size > 0) {
files.push(value);
}
});
});
return files;
}
function stringValue(value) {
return typeof value === "string" ? value : "";
}
function jsonResponse(payload) {
return new Response(JSON.stringify(payload), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
async function storeShareError(id, error) {
const cache = await caches.open(SHARE_CACHE);
await cache.put(LATEST_KEY, jsonResponse({
id,
error: error && error.message ? error.message : "Shared files could not be staged.",
createdAt: new Date().toISOString(),
files: [],
}));
}
async function deletePreviousShare(cache) {
const previous = await cache.match(LATEST_KEY);
if (!previous) {
return;
}
let metadata = null;
try {
metadata = await previous.json();
} catch (error) {
metadata = null;
}
for (const file of metadata && metadata.files ? metadata.files : []) {
if (file.key) {
await cache.delete(file.key);
}
}
if (metadata && metadata.id) {
await cache.delete(SHARE_PREFIX + "meta/" + encodeURIComponent(metadata.id));
}
await cache.delete(LATEST_KEY);
}

View File

@@ -7,6 +7,22 @@
"display": "standalone",
"background_color": "#0b0b16",
"theme_color": "#8b5cf6",
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["*/*"]
}
]
}
},
"icons": [
{
"src": "/static/android-chrome-192x192.png",