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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user