diff --git a/lib/config/config.go b/lib/config/config.go index e05c860..0ae71f4 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "math" "os" "path/filepath" "strconv" @@ -197,10 +198,6 @@ func Load() (*Config, error) { }{ {SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds}, {SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, - {SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", 0, &cfg.GlobalMaxFileSizeBytes}, - {SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", 0, &cfg.GlobalMaxBoxSizeBytes}, - {SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", 0, &cfg.DefaultUserMaxFileSizeBytes}, - {SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", 0, &cfg.DefaultUserMaxBoxSizeBytes}, {SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds}, } for _, item := range envInt64s { @@ -208,6 +205,22 @@ func Load() (*Config, error) { return nil, err } } + sizeEnvVars := []struct { + key string + mbName string + bytesName string + target *int64 + }{ + {SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes}, + {SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes}, + {SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes}, + {SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes}, + } + for _, item := range sizeEnvVars { + if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil { + return nil, err + } + } envInts := []struct { key string @@ -404,6 +417,34 @@ func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int return nil } +func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error { + if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" { + parsed, err := parseInt64(rawBytes, min) + if err != nil { + return fmt.Errorf("%s: %w", bytesName, err) + } + *target = parsed + cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv) + return nil + } + + rawMB := strings.TrimSpace(os.Getenv(mbName)) + if rawMB == "" { + return nil + } + parsedMB, err := parseInt64(rawMB, min) + if err != nil { + return fmt.Errorf("%s: %w", mbName, err) + } + if parsedMB > math.MaxInt64/(1024*1024) { + return fmt.Errorf("%s: is too large", mbName) + } + parsedBytes := parsedMB * 1024 * 1024 + *target = parsedBytes + cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv) + return nil +} + func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { diff --git a/lib/config/config_test.go b/lib/config/config_test.go index 962f2c1..d167f6c 100644 --- a/lib/config/config_test.go +++ b/lib/config/config_test.go @@ -64,6 +64,39 @@ func TestEnvironmentOverrides(t *testing.T) { } } +func TestMegabyteSizeEnvironmentOverrides(t *testing.T) { + clearConfigEnv(t) + t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048") + t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "4096") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 { + t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes) + } + if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 { + t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes) + } +} + +func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) { + clearConfigEnv(t) + t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048") + t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if cfg.GlobalMaxFileSizeBytes != 100 { + t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes) + } +} + func TestInvalidEnvironmentValues(t *testing.T) { clearConfigEnv(t) t.Setenv("WARPBOX_SESSION_TTL_SECONDS", "1") @@ -131,9 +164,13 @@ func clearConfigEnv(t *testing.T) { "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", + "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", + "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", + "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", + "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_SESSION_TTL_SECONDS", "WARPBOX_BOX_POLL_INTERVAL_MS", diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..b7462ec --- /dev/null +++ b/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Core service switches. +export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}" +export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}" +export WARPBOX_ZIP_DOWNLOADS_ENABLED="${WARPBOX_ZIP_DOWNLOADS_ENABLED:-true}" +export WARPBOX_ONE_TIME_DOWNLOADS_ENABLED="${WARPBOX_ONE_TIME_DOWNLOADS_ENABLED:-true}" + +# Storage and expiry limits used by the upload UI and backend validators. +# Use megabytes here; WarpBox converts these to bytes internally. +export WARPBOX_GLOBAL_MAX_FILE_SIZE_MB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_MB:-2048}" # 2 GiB +export WARPBOX_GLOBAL_MAX_BOX_SIZE_MB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_MB:-4096}" # 4 GiB +export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour +export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours + +# Download-page refresh and thumbnail worker tuning. +export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}" +export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}" +export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}" + +# Data location. +export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}" + +# Admin Area +export WARPBOX_ADMIN_ENABLED="${WARPBOX_ADMIN_ENABLED:-true}" +export WARPBOX_ADMIN_PASSWORD="${WARPBOX_ADMIN_PASSWORD:-123}" + +go run ./cmd/main.go run diff --git a/static/css/app.css b/static/css/app.css index fede830..04d04e4 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -22,10 +22,13 @@ :root { font-family: 'PixelOperator', 'MS Sans Serif', Arial, sans-serif; font-smooth: never; + -webkit-font-smoothing: none; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; image-rendering: pixelated; - cursor: url('/static/cursors/vaporwave-hotline-white-plus/Normal\ Select.cur'), auto; --base-font-size: 14px; + --ui-scale: 1; --w98-blue: #000078; --w98-blue-gradient: linear-gradient(90deg, #000078 0%, #000078 28%, #0f80cd 50%, #000078 72%, #000078 100%); --w98-gray: #c0c0c0; @@ -38,6 +41,7 @@ box-sizing: border-box; scrollbar-width: auto; scrollbar-color: #c0c0c0 #808080; + image-rendering: pixelated; } html { @@ -75,7 +79,7 @@ label[for], .menu-button, .win98-button:not(:disabled), a { - cursor: url('/static/cursors/vaporwave-hotline-white-plus/Link\ Select.cur'), pointer; + cursor: pointer; } button, @@ -90,7 +94,7 @@ input[type="password"], input[type="number"], input[type="file"], textarea { - cursor: url('/static/cursors/vaporwave-hotline-white-plus/Hotline\ Black\ Handwriting.cur'), text; + cursor: text; } :focus-visible { @@ -172,18 +176,19 @@ textarea:disabled { } @media (min-width: 1800px) { - :root { --base-font-size: 15px; } - .desktop-wrap { zoom: 1.2; } + :root { --base-font-size: 15px; --ui-scale: 1.2; } } @media (min-width: 2048px) { - :root { --base-font-size: 16px; } - .desktop-wrap { zoom: 1.36; } + :root { --base-font-size: 16px; --ui-scale: 1.36; } } @media (min-width: 2560px) { - :root { --base-font-size: 18px; } - .desktop-wrap { zoom: 1.58; } + :root { --base-font-size: 18px; --ui-scale: 1.58; } +} + +@media (min-width: 3200px) { + :root { --base-font-size: 20px; --ui-scale: 1.88; } } @media (prefers-reduced-motion: reduce) { diff --git a/static/css/box.css b/static/css/box.css index b9a83b2..9f97305 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -1,6 +1,7 @@ .box-window { width: min(760px, calc(100vw - 36px)); height: min(560px, calc(100vh - 36px)); + zoom: var(--ui-scale); } .box-toolbar { @@ -192,6 +193,7 @@ height: 100dvh; border: 0; box-shadow: none; + zoom: 1; } .box-titlebar { diff --git a/static/css/login.css b/static/css/login.css index 1b29697..13dd5cf 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -1,6 +1,7 @@ .login-window { width: 420px; height: 248px; + zoom: var(--ui-scale); } .login-form { @@ -109,6 +110,7 @@ height: 100dvh; border: 0; box-shadow: none; + zoom: 1; } .login-titlebar { diff --git a/static/css/upload.css b/static/css/upload.css index b929821..9803e8c 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -17,6 +17,14 @@ justify-content: center; gap: 18px; overflow: hidden; + zoom: var(--ui-scale); +} + +body.fit-window .desktop-wrap { + width: min(100%, calc(100vw / var(--ui-scale) - 20px)); + height: min(calc(100vh / var(--ui-scale) - 20px), 900px); + max-height: none; + grid-template-columns: minmax(0, 1fr) var(--side-width); } .upload-window { @@ -233,6 +241,7 @@ background-color: #000078; background-image: repeating-linear-gradient(to right, rgba(255,255,255,.12) 0 1px, transparent 1px 18px); transform-origin: left center; + position: relative; } .upload-quota-bar.is-over-quota { @@ -433,6 +442,26 @@ .upload-file-row.is-uploaded .upload-progress-bar { background-color: #008000; } .upload-file-row.is-failed .upload-progress-bar { width: 100%; background-color: #800000; } +.upload-progress-bar.just-completed, +.upload-overall-bar.just-completed { + animation: progress-impact-bar 520ms steps(5, end) 1; +} + +.upload-progress-bar.just-completed::after, +.upload-overall-bar.just-completed::after { + content: ""; + position: absolute; + right: -7px; + top: 50%; + width: 12px; + height: 22px; + transform: translateY(-50%); + background: repeating-linear-gradient(45deg, rgba(255,255,255,.95) 0 2px, rgba(0,255,102,.85) 2px 4px, transparent 4px 6px); + box-shadow: 0 0 0 1px #ffffff, 0 0 8px #00ff66; + pointer-events: none; + animation: progress-impact-spark 520ms steps(5, end) 1; +} + .upload-result { display: grid; grid-template-columns: 72px minmax(0, 1fr) 72px; @@ -828,6 +857,7 @@ max-height: min(760px, calc(100vh - 24px)); display: none; z-index: 80; + zoom: var(--ui-scale); } .popup-window.is-visible { @@ -888,6 +918,7 @@ font-size: 12px; line-height: 14px; box-shadow: 4px 4px 0 rgba(0,0,0,.45); + zoom: var(--ui-scale); } .toast.is-visible { @@ -984,6 +1015,17 @@ font-family: 'PixelOperatorMono', monospace; } +.popup-body pre { + user-select: text; + cursor: text; + padding-bottom: 22px; +} + +.popup-body pre::after { + content: "\A"; + white-space: pre; +} + .kbd { display: inline-block; min-width: 18px; @@ -1002,6 +1044,8 @@ @keyframes share-ready-pulse { 50% { filter: brightness(1.08); box-shadow: 0 0 0 2px #000078; } } @keyframes start-ready-rainbow-breathe { 0%, 100% { transform: rotate(-.35deg) scale(1); } 50% { transform: rotate(.35deg) scale(1.016); } } @keyframes start-border-rainbow-slide { from { background-position: 0% 50%; } to { background-position: 100% 50%; } } +@keyframes progress-impact-bar { 0% { filter: brightness(1); } 35% { filter: brightness(1.75); } 100% { filter: brightness(1); } } +@keyframes progress-impact-spark { 0% { opacity: 0; transform: translateY(-50%) scale(.7); } 30% { opacity: 1; transform: translateY(-50%) scale(1.18); } 100% { opacity: 0; transform: translateY(-50%) scale(.7); } } @keyframes terminal-cursor { 50% { opacity: 0; } } @keyframes popup-open-v10 { from { transform: translate(-50%, -48%) scale(.97); opacity: .35; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } } @keyframes toast-in { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } diff --git a/static/js/app.js b/static/js/app.js index a20ffe9..a683db6 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -54,6 +54,8 @@ let uploadLocked = false; let statusTimer = null; let pendingDuplicateFiles = []; let apiKeyTimer = null; +let completedImpactKeys = new Set(); +let overallImpactDone = false; function numberFromDataset(value) { const number = Number.parseInt(value || "0", 10); @@ -177,6 +179,28 @@ function showToast(message, type = "info") { showToast.timer = setTimeout(() => el.toast.classList.remove("is-visible"), 2600); } +function disabledReasonFor(target) { + const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone"); + if (!control) return ""; + if (control.classList.contains("upload-dropzone") && uploadLocked) { + return control.dataset.disabledReason || "The current box is sealed after upload. Press Clear to start a new box."; + } + if (control.disabled || control.readOnly || control.getAttribute("aria-disabled") === "true") { + return control.dataset.disabledReason || control.title || "This control is disabled right now."; + } + return ""; +} + +function announceDisabledReason(event) { + const reason = disabledReasonFor(event.target); + if (!reason) return false; + event.preventDefault(); + event.stopPropagation(); + showToast(reason, "warning"); + setStatus(reason); + return true; +} + function stopStatusAnimation() { if (statusTimer) { clearInterval(statusTimer); @@ -214,6 +238,14 @@ function setOverallProgress(percent) { if (el.overallPercent) el.overallPercent.textContent = display; } +function flashProgressBar(bar) { + if (!bar) return; + bar.classList.remove("just-completed"); + void bar.offsetWidth; + bar.classList.add("just-completed"); + setTimeout(() => bar.classList.remove("just-completed"), 620); +} + function setRowProgress(item, percent) { const bar = item.row?.querySelector(".upload-progress-bar"); if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`; @@ -275,6 +307,10 @@ function updateOverallProgress() { const uploadedCount = files.filter((item) => item.uploaded).length; const percent = overallProgress(); setOverallProgress(percent >= 100 && uploadedCount < files.length ? 99 : percent); + if (percent >= 100 && files.length && !overallImpactDone) { + overallImpactDone = true; + flashProgressBar(el.overallBar); + } } function createFileRow(item, index) { @@ -310,6 +346,7 @@ function createFileRow(item, index) { remove.dataset.remove = String(index); remove.title = uploadLocked ? "This file cannot be removed because this upload box was already created." : "Remove file"; remove.disabled = uploadLocked; + remove.dataset.disabledReason = uploadLocked ? "Files cannot be removed after the box is created. Press Clear to start another upload." : ""; const progress = document.createElement("span"); progress.className = "upload-progress"; @@ -395,17 +432,9 @@ function addFiles(fileList) { function showDuplicateDialog(duplicates) { pendingDuplicateFiles = duplicates; const list = duplicates.map((item) => `
These files have the same names as files already in the queue.
-Skip them, or append numbers so they become names like file (2).zip.
This removes the current queue, resets progress, and unlocks the Start upload button.
-The browser refused clipboard access. Copy it manually from the field below.
- -WarpBox accepts normal multipart form uploads through the compatibility endpoint:
-curl \\
- -F 'files=@./my-file.zip' \\
- -F 'retention=1h' \\
- ${window.location.origin}/upload
- The browser uses the manifest API: it creates a box, uploads each file, and marks failed uploads so the download page does not wait forever.
- `, - }, - faq: { - title: "Help & FAQ", - html: ` -Can I password protect uploads?
Yes. Set a password in Box Options before starting the upload.
What happens if one file fails?
The failed row stays red, successful files remain available, and WarpBox marks the failed file in the manifest.
Are all options server-backed?
Expiry, password, ZIP download, and one-time download are sent to the backend. Notes like box name, custom slug, and API key mode are saved locally until backend support exists.
These values come from the running WarpBox configuration.
- `, - }, - about: { - title: "About WarpBox", - about: true, - html: ` -WarpBox was made by Daniel Legt.
-Temporary file boxes, terminal-friendly uploads, and old-web UI charm.
- `, - }, - examples: { - title: "Examples", - html: ` -curl \\
- -F 'files=@./photo.png' \\
- -F 'retention=24h' \\
- ${window.location.origin}/upload
- curl \\
- -F 'files=@./one.png' \\
- -F 'files=@./two.zip' \\
- -F 'retention=1h' \\
- -F 'password=secret-pass' \\
- ${window.location.origin}/upload
- `,
- },
-};
+async function showTemplatePopup(title, templateName, data = {}, about = false) {
+ try {
+ const html = await window.WBPopups.renderTemplate(templateName, data);
+ openPopup(title, html, about);
+ } catch (error) {
+ showToast(error.message || `Could not load ${title}.`, "error");
+ }
+}
-function openDoc(name) {
- const doc = docs[name];
- if (!doc) return;
- openPopup(doc.title, doc.html, doc.about);
- setStatus(`${doc.title} opened`);
+function popupTemplateData(name) {
+ const data = { origin: window.location.origin };
+ if (name !== "dailyQuota") return data;
+ return {
+ ...data,
+ boxLimit: maxBoxBytes ? formatBytes(maxBoxBytes) : "No configured limit",
+ boxPercent: maxBoxBytes ? Math.min(100, Math.round((totalBytes() / maxBoxBytes) * 100)) : 0,
+ fileLimit: maxFileBytes ? formatBytes(maxFileBytes) : "No configured limit",
+ filePercent: oversizedFiles().length ? 100 : 0,
+ };
+}
+
+async function openDoc(name) {
+ try {
+ const doc = await window.WBPopups.renderDoc(name, popupTemplateData(name));
+ if (!doc) return;
+ openPopup(doc.title, doc.html, doc.about);
+ setStatus(`${doc.title} opened`);
+ } catch (error) {
+ showToast(error.message || "Could not load help window.", "error");
+ }
}
document.addEventListener("click", (event) => {
+ if (announceDisabledReason(event)) return;
+
const menuButton = event.target.closest(".menu-button");
if (menuButton) {
const item = menuButton.closest(".menu-item");
@@ -1067,19 +1055,16 @@ document.addEventListener("click", (event) => {
}
if (action === "help" || action === "side-help") openDoc("faq");
if (action === "terminal-help") el.terminal?.focus();
- if (action === "coming-soon") showToast("That shortcut is decorative for now.");
- if (action === "side-close" || action === "side-folder-close" || action === "fake-close" || action === "minimize" || action === "toggle-fit") showToast("Window controls are decorative on this page.");
- return;
- }
-
- const expiry = event.target.closest("[data-expiry]")?.dataset.expiry;
- if (expiry && el.expiry) {
- el.expiry.value = expiry;
- syncZipForRetention();
- saveSettings();
- syncMenuChecks();
- updateTerminal();
- setStatus(`Expiry set to ${event.target.textContent.trim()}`);
+ if (action === "coming-soon") showToast("Coming Soon, not implemented just yet.");
+ if (action === "fake-close") showToast("Close button denied. The upload window is staying open.", "warning");
+ if (action === "minimize") showToast("Minimize requested. WarpBox stays visible so your queue is safe.");
+ if (action === "toggle-fit") {
+ document.body.classList.toggle("fit-window");
+ showToast("Maximize requested. The pixel rectangle feels important now.");
+ }
+ if (action === "side-close") showToast("Box Options refuses to leave. Settings stay visible.");
+ if (action === "side-help") showToast("Terminal help opened. Copy the command and feed it files.");
+ if (action === "side-folder-close") showToast("The folder window saw that click and chose denial.");
return;
}
@@ -1115,6 +1100,22 @@ document.addEventListener("click", (event) => {
}
});
+document.addEventListener("mousedown", (event) => {
+ announceDisabledReason(event);
+}, true);
+
+document.querySelectorAll(".menu-item").forEach((item) => {
+ item.addEventListener("mouseenter", () => {
+ if (!document.querySelector(".menu-item.is-open")) return;
+ document.querySelectorAll(".menu-item.is-open").forEach((node) => {
+ node.classList.remove("is-open");
+ node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
+ });
+ item.classList.add("is-open");
+ item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
+ });
+});
+
el.fileInput?.addEventListener("change", () => addFiles(el.fileInput.files));
[el.dropSurface, el.dropzone].filter(Boolean).forEach((target) => {
@@ -1150,7 +1151,11 @@ el.modalBackdrop?.addEventListener("click", closeDoc);
[el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean).forEach((control) => {
control.addEventListener("input", () => {
if (control === el.boxName) syncSlugFromName();
- if (control === el.customSlug) el.customSlug.dataset.auto = "false";
+ if (control === el.customSlug) {
+ const clean = sanitizeSlugInput(el.customSlug.value);
+ if (el.customSlug.value !== clean) el.customSlug.value = clean;
+ el.customSlug.dataset.auto = "false";
+ }
if (control === el.apiKeyInput) validateApiKeyField();
saveSettings();
updateTerminal();
diff --git a/static/js/upload-popups.js b/static/js/upload-popups.js
new file mode 100644
index 0000000..5953a75
--- /dev/null
+++ b/static/js/upload-popups.js
@@ -0,0 +1,36 @@
+window.WBPopups = (() => {
+ const cache = new Map();
+ const docs = {
+ cli: { title: "CLI Guide", template: "cli" },
+ faq: { title: "Help & FAQ", template: "faq" },
+ dailyQuota: { title: "Upload limits", template: "upload-limits" },
+ about: { title: "About WarpBox", template: "about", about: true },
+ examples: { title: "Examples", template: "examples" },
+ };
+
+ async function loadTemplate(name) {
+ if (cache.has(name)) return cache.get(name);
+ const response = await fetch(`/static/popups/${name}.html`, { credentials: "same-origin" });
+ if (!response.ok) throw new Error(`Could not load popup template: ${name}`);
+ const template = await response.text();
+ cache.set(name, template);
+ return template;
+ }
+
+ async function renderTemplate(name, data = {}) {
+ const template = await loadTemplate(name);
+ return window.WBUtils.renderTemplate(template, data);
+ }
+
+ async function renderDoc(name, data = {}) {
+ const doc = docs[name];
+ if (!doc) return null;
+ return {
+ title: doc.title,
+ about: Boolean(doc.about),
+ html: await renderTemplate(doc.template, data),
+ };
+ }
+
+ return { renderTemplate, renderDoc };
+})();
diff --git a/static/js/upload-utils.js b/static/js/upload-utils.js
new file mode 100644
index 0000000..fd4459b
--- /dev/null
+++ b/static/js/upload-utils.js
@@ -0,0 +1,9 @@
+window.WBUtils = (() => {
+ function renderTemplate(template, data = {}) {
+ return String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
+ return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : "";
+ });
+ }
+
+ return { renderTemplate };
+})();
diff --git a/static/popups/about.html b/static/popups/about.html
new file mode 100644
index 0000000..c82a9cf
--- /dev/null
+++ b/static/popups/about.html
@@ -0,0 +1,3 @@
+WarpBox was made by Daniel Legt.
+Temporary file boxes, terminal-friendly uploads, and old-web UI charm.
diff --git a/static/popups/clear.html b/static/popups/clear.html new file mode 100644 index 0000000..65357e7 --- /dev/null +++ b/static/popups/clear.html @@ -0,0 +1,6 @@ +This removes the current queue, resets progress, and unlocks the Start upload button.
+WarpBox accepts normal multipart form uploads through the compatibility endpoint:
+curl \
+ -F 'files=@./my-file.zip' \
+ -F 'retention=1h' \
+ {{ origin }}/upload
+
+The browser uses the manifest API: it creates a box, uploads each file, and marks failed uploads so the download page does not wait forever.
diff --git a/static/popups/copy-failed.html b/static/popups/copy-failed.html new file mode 100644 index 0000000..5e8aa24 --- /dev/null +++ b/static/popups/copy-failed.html @@ -0,0 +1,7 @@ +The browser refused clipboard access. Copy it manually from the field below.
+ +These files have the same names as files already in the queue.
+Skip them, or append numbers so they become names like file (2).zip.
curl \
+ -F 'files=@./photo.png' \
+ -F 'retention=24h' \
+ {{ origin }}/upload
+
+curl \
+ -F 'files=@./one.png' \
+ -F 'files=@./two.zip' \
+ -F 'retention=1h' \
+ -F 'password=secret-pass' \
+ {{ origin }}/upload
+
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+)
+
+func main() {
+ file, err := os.Open("photo.png")
+ if err != nil { panic(err) }
+ defer file.Close()
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile("files", "photo.png")
+ if err != nil { panic(err) }
+ if _, err := io.Copy(part, file); err != nil { panic(err) }
+ _ = writer.WriteField("retention", "1h")
+ writer.Close()
+
+ req, err := http.NewRequest("POST", "{{ origin }}/upload", &body)
+ if err != nil { panic(err) }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil { panic(err) }
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ fmt.Println(string(out))
+}
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+public class UploadWarpBox {
+ public static void main(String[] args) throws Exception {
+ String boundary = "----WarpBoxBoundary" + System.currentTimeMillis();
+ Path file = Path.of("photo.png");
+ byte[] prefix = ("--" + boundary + "\r\n" +
+ "Content-Disposition: form-data; name=\"retention\"\r\n\r\n" +
+ "1h\r\n" +
+ "--" + boundary + "\r\n" +
+ "Content-Disposition: form-data; name=\"files\"; filename=\"photo.png\"\r\n" +
+ "Content-Type: application/octet-stream\r\n\r\n").getBytes();
+ byte[] suffix = ("\r\n--" + boundary + "--\r\n").getBytes();
+ byte[] fileBytes = Files.readAllBytes(file);
+ byte[] body = new byte[prefix.length + fileBytes.length + suffix.length];
+ System.arraycopy(prefix, 0, body, 0, prefix.length);
+ System.arraycopy(fileBytes, 0, body, prefix.length, fileBytes.length);
+ System.arraycopy(suffix, 0, body, prefix.length + fileBytes.length, suffix.length);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("{{ origin }}/upload"))
+ .header("Content-Type", "multipart/form-data; boundary=" + boundary)
+ .POST(HttpRequest.BodyPublishers.ofByteArray(body))
+ .build();
+ HttpResponse<String> response = HttpClient.newHttpClient()
+ .send(request, HttpResponse.BodyHandlers.ofString());
+ System.out.println(response.body());
+ }
+}
+
+import { openAsBlob } from 'node:fs';
+
+const file = await openAsBlob('./photo.png');
+const form = new FormData();
+form.append('files', file, 'photo.png');
+form.append('retention', '1h');
+
+const res = await fetch('{{ origin }}/upload', {
+ method: 'POST',
+ body: form
+});
+
+console.log(await res.text());
+
diff --git a/static/popups/faq.html b/static/popups/faq.html
new file mode 100644
index 0000000..4bf86f8
--- /dev/null
+++ b/static/popups/faq.html
@@ -0,0 +1,17 @@
+Can I password protect uploads?
Yes. Set a password in Box Options before starting the upload.
What happens if one file fails?
The failed row stays red, successful files remain available, and WarpBox marks the failed file in the manifest.
Are all options server-backed?
Expiry, password, ZIP download, and one-time download are sent to the backend. Notes like box name, custom slug, and API key mode are saved locally until backend support exists.
These values come from the running WarpBox configuration.
diff --git a/static/popups/warning.html b/static/popups/warning.html new file mode 100644 index 0000000..694c8a9 --- /dev/null +++ b/static/popups/warning.html @@ -0,0 +1,5 @@ +