feat: support folder uploads and sanitize upload paths

- Implement `cleanUploadDisplayName` in the backend to safely sanitize uploaded file paths, preserving directory structures while stripping unsafe characters and preventing path traversal.
- Add folder upload capability in the frontend using the Directory Picker API.
- Implement desktop notifications for completed uploads.
This commit is contained in:
2026-06-10 18:14:29 +03:00
parent 0b8d4a3ab9
commit 5d77b36634
7 changed files with 299 additions and 19 deletions

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ backend/static/uploads/*
scripts/env/dev.env
docker-compose.yml
.claude
.claude
docs/possible_new_features

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4M5 20h14" /></svg>
</span>
<span class="drop-title">Drop files to upload</span>
<span class="drop-copy">or click to browse</span>
<span class="drop-copy">or click to browse, paste files, or drop a folder</span>
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
<input id="file-input" name="file" type="file" multiple>
</label>
@@ -77,6 +77,7 @@
<div class="form-footer">
<p id="file-summary">Choose one or more files to begin.</p>
<button class="button button-outline install-pwa-button" type="button" data-install-pwa hidden>Install Warpbox</button>
<button class="button button-outline folder-picker-button" type="button" data-folder-picker hidden>Choose folder</button>
<button class="button button-primary" type="submit">Upload files</button>
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
</div>