From 6035ea1eb24537ef5ccb934cb4ecc0a15fe64396 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Wed, 29 Apr 2026 01:42:41 +0300 Subject: [PATCH] feat(config): support *_MB env vars for upload size limits - Add `applyMegabytesOrBytesEnv` to accept size settings in either bytes or MB - Prefer `*_BYTES` when set, otherwise convert `*_MB` to bytes with overflow guard - Add coverage for MB-based environment overrides - Introduce `static/js/upload-popups.js` to lazy-load and cache popup templatesfeat(config): support *_MB env vars for upload size limits - Add `applyMegabytesOrBytesEnv` to accept size settings in either bytes or MB - Prefer `*_BYTES` when set, otherwise convert `*_MB` to bytes with overflow guard - Add coverage for MB-based environment overrides - Introduce `static/js/upload-popups.js` to lazy-load and cache popup templates --- lib/config/config.go | 49 +++++- lib/config/config_test.go | 37 +++++ run.sh | 29 ++++ static/css/app.css | 23 ++- static/css/box.css | 2 + static/css/login.css | 2 + static/css/upload.css | 44 +++++ static/js/app.js | 277 ++++++++++++++++--------------- static/js/upload-popups.js | 36 ++++ static/js/upload-utils.js | 9 + static/popups/about.html | 3 + static/popups/clear.html | 6 + static/popups/cli.html | 9 + static/popups/copy-failed.html | 7 + static/popups/duplicate.html | 8 + static/popups/examples.html | 102 ++++++++++++ static/popups/faq.html | 17 ++ static/popups/upload-limits.html | 12 ++ static/popups/warning.html | 5 + templates/index.html | 33 ++-- 20 files changed, 544 insertions(+), 166 deletions(-) create mode 100755 run.sh create mode 100644 static/js/upload-popups.js create mode 100644 static/js/upload-utils.js create mode 100644 static/popups/about.html create mode 100644 static/popups/clear.html create mode 100644 static/popups/cli.html create mode 100644 static/popups/copy-failed.html create mode 100644 static/popups/duplicate.html create mode 100644 static/popups/examples.html create mode 100644 static/popups/faq.html create mode 100644 static/popups/upload-limits.html create mode 100644 static/popups/warning.html 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) => `
  • ${htmlEscape(item.displayName)} ${formatBytes(item.file.size)}
  • `).join(""); - openPopup("Duplicate file names", ` -

    Duplicate file names detected

    -

    These files have the same names as files already in the queue.

    -
      ${list}
    -

    Skip them, or append numbers so they become names like file (2).zip.

    -
    - - -
    `); + showTemplatePopup("Duplicate file names", "duplicate", { list }) + .then(() => document.querySelector("#duplicate-append")?.focus()); showToast("Duplicate names found. Choose skip or append numbers.", "warning"); - setTimeout(() => document.querySelector("#duplicate-append")?.focus(), 0); } function appendPendingDuplicates() { @@ -443,6 +472,8 @@ function clearQueue() { files = []; pendingDuplicateFiles = []; uploadLocked = false; + completedImpactKeys = new Set(); + overallImpactDone = false; stopStatusAnimation(); setBoxOptionsLocked(false); setShareUrl(""); @@ -461,14 +492,8 @@ function confirmClearQueue() { showToast("Nothing to clear."); return; } - openPopup("Clear WarpBox?", ` -

    Confirm clear

    -

    This removes the current queue, resets progress, and unlocks the Start upload button.

    -
    - - -
    `); - setTimeout(() => document.querySelector("#confirm-clear-no")?.focus(), 0); + showTemplatePopup("Clear WarpBox?", "clear") + .then(() => document.querySelector("#confirm-clear-no")?.focus()); } async function createBox() { @@ -521,6 +546,13 @@ function setFileFailed(item, message) { updateOverallProgress(); } +function markCompletedImpact(item) { + const key = item.boxFile?.id || item.displayName; + if (completedImpactKeys.has(key)) return; + completedImpactKeys.add(key); + flashProgressBar(item.row?.querySelector(".upload-progress-bar")); +} + function uploadFile(item, onComplete) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -566,6 +598,7 @@ function uploadFile(item, onComplete) { item.row?.classList.add("is-uploaded"); if (item.row) item.row.title = "Uploaded"; setRowProgress(item, 100); + markCompletedImpact(item); try { const result = JSON.parse(xhr.responseText); @@ -635,6 +668,8 @@ async function startUpload() { item.failed = false; item.error = ""; }); + completedImpactKeys = new Set(); + overallImpactDone = false; renderFiles(); let completedCount = 0; @@ -731,6 +766,12 @@ function updateDisabledReasons() { el.startButton.dataset.disabledReason = reason; el.startButton.title = reason; } + if (el.fileInput) { + el.fileInput.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : ""); + } + if (el.dropzone) { + el.dropzone.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : ""); + } } function saveSettings() { @@ -773,9 +814,6 @@ function loadSettings() { } function syncMenuChecks() { - document.querySelectorAll("[data-expiry-check]").forEach((node) => { - node.textContent = node.dataset.expiryCheck === el.expiry?.value ? "✓" : ""; - }); const downloadCheck = document.querySelector("[data-download-page-check]"); if (downloadCheck) downloadCheck.textContent = el.downloadPage?.checked ? "✓" : ""; } @@ -826,6 +864,14 @@ function slugify(value) { .slice(0, 32); } +function sanitizeSlugInput(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-") + .slice(0, 32); +} + function syncSlugFromName(force = false) { if (!el.customSlug || !el.boxName) return; if (force || !el.customSlug.value || el.customSlug.dataset.auto === "true") { @@ -846,9 +892,9 @@ function randomPassword() { function randomBoxName() { if (!el.boxName || uploadLocked) return; - const adjectives = ["neon", "turbo", "quiet", "cosmic", "lucky", "midnight", "pixel", "rapid"]; - const nouns = ["floppy", "archive", "packet", "portal", "folder", "upload", "cache", "drive"]; - el.boxName.value = `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}`; + const adjectives = ["Neon", "Turbo", "Quiet", "Cosmic", "Lucky", "Midnight", "Pixel", "Rapid"]; + const nouns = ["Floppy Disk", "Archive Box", "Packet Portal", "Upload Folder", "Cache Drive", "Release Bundle"]; + el.boxName.value = `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${nouns[Math.floor(Math.random() * nouns.length)]}`; syncSlugFromName(true); setStatus("Generated a local box name"); } @@ -891,14 +937,11 @@ async function copyText(kind, value, openUrl = "") { } function showCopyFallback(kind, value, openUrl) { - openPopup(`${kind} copy failed`, ` -

    Clipboard access failed

    -

    The browser refused clipboard access. Copy it manually from the field below.

    - -
    - ${openUrl ? `Open` : ""} - -
    `); + const openLink = openUrl ? `Open` : ""; + showTemplatePopup(`${kind} copy failed`, "copy-failed", { + value: htmlEscape(value), + openLink, + }); } function quotaWarningHtml(message) { @@ -916,10 +959,10 @@ function quotaWarningHtml(message) { } function showWarningDialog(title, message) { - openPopup(title, ` -

    ${htmlEscape(title)}

    - ${quotaWarningHtml(message)} -
    `); + showTemplatePopup(title, "warning", { + title: htmlEscape(title), + content: quotaWarningHtml(message), + }); } function openPopup(title, html, about = false) { @@ -936,96 +979,41 @@ function closeDoc() { el.modalBackdrop?.classList.remove("is-visible"); } -const docs = { - cli: { - title: "CLI Guide", - html: ` -

    Upload with cURL

    -

    WarpBox accepts normal multipart form uploads through the compatibility endpoint:

    -
    curl \\
    -  -F 'files=@./my-file.zip' \\
    -  -F 'retention=1h' \\
    -  ${window.location.origin}/upload
    -

    Browser flow

    -

    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: ` -

    Help & FAQ

    -
    -

    Keyboard shortcuts

    - -
    -
    -

    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.

    -
    - `, - }, - dailyQuota: { - title: "Upload limits", - html: ` -

    Upload limits

    -
    -
    -
    Box size${maxBoxBytes ? formatBytes(maxBoxBytes) : "No configured limit"}
    -
    -
    -
    -
    Single file${maxFileBytes ? formatBytes(maxFileBytes) : "No configured limit"}
    -
    -
    -
    -

    These values come from the running WarpBox configuration.

    - `, - }, - about: { - title: "About WarpBox", - about: true, - html: ` -

    WarpBox

    -

    WarpBox was made by Daniel Legt.

    -

    Temporary file boxes, terminal-friendly uploads, and old-web UI charm.

    - `, - }, - examples: { - title: "Examples", - html: ` -

    Upload examples

    -

    Basic CLI upload

    -
    curl \\
    -  -F 'files=@./photo.png' \\
    -  -F 'retention=24h' \\
    -  ${window.location.origin}/upload
    -

    Multiple files with password

    -
    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

    +

    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 @@ +

    Confirm clear

    +

    This removes the current queue, resets progress, and unlocks the Start upload button.

    +
    + + +
    diff --git a/static/popups/cli.html b/static/popups/cli.html new file mode 100644 index 0000000..f55986c --- /dev/null +++ b/static/popups/cli.html @@ -0,0 +1,9 @@ +

    Upload with cURL

    +

    WarpBox accepts normal multipart form uploads through the compatibility endpoint:

    +
    curl \
    +  -F 'files=@./my-file.zip' \
    +  -F 'retention=1h' \
    +  {{ origin }}/upload
    +
    +

    Browser flow

    +

    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 @@ +

    Clipboard access failed

    +

    The browser refused clipboard access. Copy it manually from the field below.

    + +
    + {{ openLink }} + +
    diff --git a/static/popups/duplicate.html b/static/popups/duplicate.html new file mode 100644 index 0000000..1dde149 --- /dev/null +++ b/static/popups/duplicate.html @@ -0,0 +1,8 @@ +

    Duplicate file names detected

    +

    These files have the same names as files already in the queue.

    +
      {{ list }}
    +

    Skip them, or append numbers so they become names like file (2).zip.

    +
    + + +
    diff --git a/static/popups/examples.html b/static/popups/examples.html new file mode 100644 index 0000000..79c6f99 --- /dev/null +++ b/static/popups/examples.html @@ -0,0 +1,102 @@ +

    Upload examples

    +

    Basic CLI upload

    +
    curl \
    +  -F 'files=@./photo.png' \
    +  -F 'retention=24h' \
    +  {{ origin }}/upload
    +
    +

    Multiple files with password

    +
    curl \
    +  -F 'files=@./one.png' \
    +  -F 'files=@./two.zip' \
    +  -F 'retention=1h' \
    +  -F 'password=secret-pass' \
    +  {{ origin }}/upload
    +
    +

    Go

    +
    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))
    +}
    +
    +

    Java 11+ HttpClient

    +
    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());
    +  }
    +}
    +
    +

    JavaScript Node.js

    +
    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 @@ +

    Help & FAQ

    +
    +

    Keyboard shortcuts

    + +
    +
    +

    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.

    +
    diff --git a/static/popups/upload-limits.html b/static/popups/upload-limits.html new file mode 100644 index 0000000..747c3ba --- /dev/null +++ b/static/popups/upload-limits.html @@ -0,0 +1,12 @@ +

    Upload limits

    +
    +
    +
    Box size{{ boxLimit }}
    +
    +
    +
    +
    Single file{{ fileLimit }}
    +
    +
    +
    +

    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 @@ +

    {{ title }}

    +{{ content }} +
    + +
    diff --git a/templates/index.html b/templates/index.html index 8edfdbc..e4cdd62 100644 --- a/templates/index.html +++ b/templates/index.html @@ -40,11 +40,8 @@ @@ -53,16 +50,16 @@ @@ -81,7 +78,7 @@ -