diff --git a/.gitignore b/.gitignore index 44fdb02..9c1589b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ backend/static/uploads/* scripts/env/dev.env docker-compose.yml -.claude \ No newline at end of file +.claude +docs/possible_new_features \ No newline at end of file diff --git a/backend/libs/services/resumable.go b/backend/libs/services/resumable.go index cbb79c3..baff1f6 100644 --- a/backend/libs/services/resumable.go +++ b/backend/libs/services/resumable.go @@ -319,7 +319,7 @@ func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (Uplo } box.Files = append(box.Files, File{ ID: fileID, - Name: filepath.Base(incoming.Name), + Name: cleanUploadDisplayName(incoming.Name), StoredName: storedName, Size: incoming.Size, ContentType: contentType, @@ -557,7 +557,7 @@ func (s *UploadService) saveResumableSession(session ResumableSession) error { func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) { sessionFiles := make([]ResumableFile, 0, len(files)) for _, file := range files { - file.Name = filepath.Base(strings.TrimSpace(file.Name)) + file.Name = cleanUploadDisplayName(file.Name) if file.Name == "." || file.Name == "" { return nil, fmt.Errorf("file name is required") } @@ -594,7 +594,7 @@ func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts } func resumableFileKey(name string, size int64, fingerprint string) string { - return strings.TrimSpace(fingerprint) + "|" + filepath.Base(strings.TrimSpace(name)) + "|" + fmt.Sprintf("%d", size) + return strings.TrimSpace(fingerprint) + "|" + cleanUploadDisplayName(name) + "|" + fmt.Sprintf("%d", size) } type resumableIncomingFile struct { diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 620ae80..4dc5577 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -16,6 +16,7 @@ import ( "mime/multipart" "net/http" "os" + "path" "path/filepath" "sort" "strings" @@ -452,7 +453,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f box.Files = append(box.Files, File{ ID: fileID, - Name: filepath.Base(incoming.Name()), + Name: cleanUploadDisplayName(incoming.Name()), StoredName: storedName, Size: incoming.Size(), ContentType: contentType, @@ -464,6 +465,36 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f return nil } +func cleanUploadDisplayName(name string) string { + clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/")) + clean = strings.TrimLeft(clean, "/") + clean = path.Clean(clean) + if clean == "." || clean == "/" || clean == "" { + return "download" + } + parts := strings.Split(clean, "/") + safeParts := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" || part == "." || part == ".." { + continue + } + part = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f || r == '/' || r == '\\' { + return -1 + } + return r + }, part) + if part != "" { + safeParts = append(safeParts, part) + } + } + if len(safeParts) == 0 { + return "download" + } + return strings.Join(safeParts, "/") +} + func (s *UploadService) GetBox(id string) (Box, error) { var box Box err := s.db.View(func(tx *bbolt.Tx) error { diff --git a/backend/static/js/40-upload.js b/backend/static/js/40-upload.js index 4965d48..e7d1896 100644 --- a/backend/static/js/40-upload.js +++ b/backend/static/js/40-upload.js @@ -14,6 +14,7 @@ const openBox = document.querySelector("#open-box"); const manageLink = document.querySelector("#manage-link"); const newUpload = document.querySelector("#new-upload"); + const folderPicker = document.querySelector("[data-folder-picker]"); const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; const SHARE_CACHE = "warpbox-share-target-v1"; const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest"; @@ -75,18 +76,18 @@ }); document.addEventListener("drop", (event) => { - if (!event.dataTransfer || !event.dataTransfer.files.length) { + if (!hasTransferFiles(event.dataTransfer)) { return; } event.preventDefault(); if (!dropZone.contains(event.target)) { - addSelectedFiles(event.dataTransfer.files); + addDroppedFiles(event.dataTransfer); } }); dropZone.addEventListener("drop", (event) => { - if (event.dataTransfer && event.dataTransfer.files.length > 0) { - addSelectedFiles(event.dataTransfer.files); + if (hasTransferFiles(event.dataTransfer)) { + addDroppedFiles(event.dataTransfer); } }); @@ -95,6 +96,36 @@ fileInput.value = ""; }); + document.addEventListener("paste", (event) => { + if (!event.clipboardData || !event.clipboardData.files || event.clipboardData.files.length === 0) { + return; + } + if (isTextEditingTarget(event.target)) { + return; + } + event.preventDefault(); + addSelectedFiles(event.clipboardData.files, { source: "pasted" }); + }); + + if (folderPicker && typeof window.showDirectoryPicker === "function") { + folderPicker.hidden = false; + folderPicker.addEventListener("click", async () => { + if (uploadLocked) { + return; + } + try { + updateStatus("Reading folder..."); + const directory = await window.showDirectoryPicker(); + const files = await filesFromDirectoryHandle(directory, directory.name || ""); + addSelectedFiles(files, { source: "folder" }); + } catch (error) { + if (!error || error.name !== "AbortError") { + updateStatus("Folder could not be read."); + } + } + }); + } + form.addEventListener("submit", async (event) => { event.preventDefault(); if (selectedFiles.length === 0) { @@ -116,6 +147,7 @@ const submit = form.querySelector("button[type='submit']"); const formData = uploadFormData(); + await maybeRequestUploadNotificationPermission(selectedFiles); if (resumeMode && recoveredDraft) { renderResumeQueue(recoveredDraft.session, selectedFiles); } else { @@ -126,6 +158,7 @@ try { const payload = await uploadResumable(form.action, formData, selectedFiles); renderResult(payload); + showUploadNotification("Warpbox upload complete", `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} uploaded.`, payload.boxUrl); await clearSharedTargetPayload(); form.reset(); selectedFiles = []; @@ -144,6 +177,7 @@ } catch (error) { updateStatus(error.message || "Upload failed"); notifyUploadError(error); + showUploadNotification("Warpbox upload failed", error.message || "Upload failed"); } finally { setLoading(false, submit); } @@ -173,7 +207,7 @@ recoverResumableSessions(); } - function addSelectedFiles(files) { + function addSelectedFiles(files, options) { if (uploadLocked) { return; } @@ -190,9 +224,132 @@ if (rejected.length > 0) { notifyRejectedFiles(rejected); } + if (options && options.source === "pasted" && files && files.length > 0) { + updateStatus(`${files.length} pasted file${files.length === 1 ? "" : "s"} ready.`); + } + if (options && options.source === "folder" && files && files.length > 0) { + updateStatus(`${files.length} folder file${files.length === 1 ? "" : "s"} ready.`); + } updateSelectedState(); } + async function addDroppedFiles(dataTransfer) { + if (uploadLocked) { + return; + } + const files = await filesFromDataTransfer(dataTransfer); + addSelectedFiles(files, { source: hasDirectoryItems(dataTransfer) ? "folder" : "dropped" }); + } + + async function filesFromDataTransfer(dataTransfer) { + const items = Array.from(dataTransfer.items || []); + const entries = items + .map((item) => typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null) + .filter(Boolean); + if (entries.length === 0) { + return Array.from(dataTransfer.files || []); + } + const nested = await Promise.all(entries.map((entry) => filesFromEntry(entry, ""))); + return nested.flat(); + } + + function hasDirectoryItems(dataTransfer) { + return Array.from(dataTransfer.items || []).some((item) => { + const entry = typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null; + return entry && entry.isDirectory; + }); + } + + function hasTransferFiles(dataTransfer) { + if (!dataTransfer) { + return false; + } + if (dataTransfer.files && dataTransfer.files.length > 0) { + return true; + } + return Array.from(dataTransfer.items || []).some((item) => item.kind === "file"); + } + + function filesFromEntry(entry, parentPath) { + if (!entry) { + return Promise.resolve([]); + } + const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name; + if (entry.isFile) { + return new Promise((resolve) => { + entry.file((file) => resolve([withRelativePath(file, relativePath)]), () => resolve([])); + }); + } + if (!entry.isDirectory) { + return Promise.resolve([]); + } + const reader = entry.createReader(); + const children = []; + return new Promise((resolve) => { + const readBatch = () => { + reader.readEntries(async (entries) => { + if (!entries.length) { + const nested = await Promise.all(children.map((child) => filesFromEntry(child, relativePath))); + resolve(nested.flat()); + return; + } + children.push(...entries); + readBatch(); + }, () => resolve([])); + }; + readBatch(); + }); + } + + async function filesFromDirectoryHandle(directory, parentPath) { + const files = []; + for await (const [name, handle] of directory.entries()) { + const relativePath = parentPath ? `${parentPath}/${name}` : name; + if (handle.kind === "file") { + const file = await handle.getFile(); + files.push(withRelativePath(file, relativePath)); + } else if (handle.kind === "directory") { + files.push(...await filesFromDirectoryHandle(handle, relativePath)); + } + } + return files; + } + + function withRelativePath(file, relativePath) { + if (!file || !relativePath) { + return file; + } + try { + Object.defineProperty(file, "warpboxRelativePath", { + value: normalizeRelativePath(relativePath), + configurable: true, + }); + } catch (error) { + file.warpboxRelativePath = normalizeRelativePath(relativePath); + } + return file; + } + + function normalizeRelativePath(value) { + return String(value || "") + .replace(/\\/g, "/") + .split("/") + .filter((part) => part && part !== "." && part !== "..") + .join("/"); + } + + function uploadName(file) { + return normalizeRelativePath(file && (file.warpboxRelativePath || file.webkitRelativePath || file.name)) || (file && file.name) || "file"; + } + + function isTextEditingTarget(target) { + if (!target) { + return false; + } + const tag = (target.tagName || "").toLowerCase(); + return tag === "input" || tag === "textarea" || target.isContentEditable; + } + function fileExceedsUploadLimit(file) { return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes; } @@ -229,6 +386,49 @@ }); } + async function maybeRequestUploadNotificationPermission(files) { + if (!("Notification" in window) || Notification.permission !== "default" || totalSelectedBytes(files) < CELLULAR_WARNING_THRESHOLD_BYTES) { + return; + } + try { + await Notification.requestPermission(); + } catch (error) { + /* notification permission is optional */ + } + } + + async function showUploadNotification(title, body, url) { + if (!("Notification" in window) || Notification.permission !== "granted") { + return; + } + if (document.visibilityState === "visible") { + return; + } + const options = { + body, + icon: "/static/android-chrome-192x192.png", + badge: "/static/favicon-32x32.png", + data: { url: window.Warpbox.absoluteURL(url || "/") }, + }; + try { + const registration = await navigator.serviceWorker?.ready; + if (registration && registration.showNotification) { + await registration.showNotification(title, options); + return; + } + } catch (error) { + /* fall through to page notification */ + } + const notification = new Notification(title, options); + notification.onclick = () => { + window.focus(); + if (url) { + window.location.href = window.Warpbox.absoluteURL(url); + } + notification.close(); + }; + } + function notify(variant, message, options) { if (window.Warpbox && typeof window.Warpbox.notify === "function") { window.Warpbox.notify({ ...(options || {}), variant, message }); @@ -555,7 +755,7 @@ const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file))); const createPayload = { files: files.map((file, index) => ({ - name: file.name, + name: uploadName(file), size: file.size, contentType: file.type || "application/octet-stream", fingerprint: fingerprints[index], @@ -1082,7 +1282,7 @@ const rows = []; const localByNameSize = new Map(); (localFiles || []).forEach((file, index) => { - localByNameSize.set(`${file.name}:${file.size}`, { file, index }); + localByNameSize.set(`${uploadName(file)}:${file.size}`, { file, index }); }); const usedLocalIndexes = new Set(); (session.files || []).forEach((file) => { @@ -1093,7 +1293,7 @@ usedLocalIndexes.add(localMatch.index); } rows.push({ - name: file.name, + name: uploadName(file), size: file.size, uploadedBytes, meta: complete @@ -1113,7 +1313,7 @@ return; } rows.push({ - name: file.name, + name: uploadName(file), meta: `${window.Warpbox.formatBytes(file.size)} · new file`, progress: 0, status: "queued", @@ -1142,7 +1342,7 @@ uploadQueue.replaceChildren(); files.forEach((file, index) => { uploadQueue.append(createFileRow({ - name: file.name, + name: uploadName(file), meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size), progress: status === "queued" ? 0 : 100, status, @@ -1211,13 +1411,13 @@ const formData = new FormData(form); formData.delete("file"); selectedFiles.forEach((file) => { - formData.append("file", file, file.name); + formData.append("file", file, uploadName(file)); }); return formData; } function fileIdentity(file) { - return [file.name, file.size, file.lastModified || 0].join(":"); + return [uploadName(file), file.size, file.lastModified || 0].join(":"); } async function fileFingerprint(file) { @@ -1226,7 +1426,7 @@ } const sampleSize = Math.min(file.size, 1024 * 1024); const sample = await file.slice(0, sampleSize).arrayBuffer(); - const metadata = new TextEncoder().encode([file.name, file.size, file.lastModified || 0, sampleSize].join(":")); + const metadata = new TextEncoder().encode([uploadName(file), file.size, file.lastModified || 0, sampleSize].join(":")); const combined = new Uint8Array(metadata.byteLength + sample.byteLength); combined.set(metadata, 0); combined.set(new Uint8Array(sample), metadata.byteLength); diff --git a/backend/static/js/45-preview.js b/backend/static/js/45-preview.js index b893b26..714bc0f 100644 --- a/backend/static/js/45-preview.js +++ b/backend/static/js/45-preview.js @@ -66,6 +66,7 @@ bindLargeGate(); bindThemeChanges(); bindRenderFullscreen(); + configureMediaSession(); renderTabs(); selectMode(state.defaultMode); @@ -301,6 +302,32 @@ document.addEventListener("fullscreenchange", updateRenderFullscreenButton); } + function configureMediaSession() { + if (!("mediaSession" in navigator) || typeof window.MediaMetadata !== "function") { + return; + } + if (!fileType.isAudio && !fileType.isVideo) { + return; + } + var artworkURL = ""; + if (fileType.isVideo && els.videoPane) { + artworkURL = els.videoPane.getAttribute("poster") || state.iconURL || ""; + } else { + artworkURL = state.iconURL || ""; + } + var metadata = { + title: state.fileName || "Warpbox media", + artist: "Warpbox", + album: state.sizeLabel || state.contentType || "" + }; + if (artworkURL) { + metadata.artwork = [ + { src: window.Warpbox.absoluteURL(artworkURL), sizes: "512x512", type: "image/png" } + ]; + } + navigator.mediaSession.metadata = new MediaMetadata(metadata); + } + function ensureTextLoaded() { if (state.textLoaded) { return Promise.resolve(state.textSource); diff --git a/backend/static/js/service-worker.js b/backend/static/js/service-worker.js index a68f546..d2ea24a 100644 --- a/backend/static/js/service-worker.js +++ b/backend/static/js/service-worker.js @@ -5,6 +5,26 @@ self.addEventListener("fetch", (event) => { } }); +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/"; + event.waitUntil((async () => { + const windows = await clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of windows) { + if ("focus" in client) { + await client.focus(); + if ("navigate" in client) { + await client.navigate(url); + } + return; + } + } + if (clients.openWindow) { + await clients.openWindow(url); + } + })()); +}); + const SHARE_CACHE = "warpbox-share-target-v1"; const SHARE_PREFIX = "/__warpbox_share_target__/"; const LATEST_KEY = SHARE_PREFIX + "latest"; diff --git a/backend/templates/pages/home.html b/backend/templates/pages/home.html index 1604065..49680e7 100644 --- a/backend/templates/pages/home.html +++ b/backend/templates/pages/home.html @@ -25,7 +25,7 @@ Drop files to upload - or click to browse + or click to browse, paste files, or drop a folder Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}} @@ -77,6 +77,7 @@