Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,54 @@ pub async fn pick_and_upload_image(
Ok(Some(descriptor))
}

const MAX_DROPPED_FILES: usize = 32;

fn validate_dropped_path(raw: &str) -> Result<std::path::PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("empty dropped path".to_string());
}
let path = std::path::PathBuf::from(trimmed);
if !path.is_absolute() {
return Err("dropped path must be absolute".to_string());
}
let meta = std::fs::metadata(&path).map_err(|e| format!("cannot read dropped file: {e}"))?;
if !meta.is_file() {
return Err("dropped path must be a regular file".to_string());
}
Ok(path)
}

/// Upload files dropped from the OS file manager onto the composer.
///
/// Linux WebKitGTK often delivers `file://` URIs / absolute paths instead of
/// `File` objects. The renderer recovers those paths and this command opens
/// them through the same TOCTOU-safe pipeline as [`pick_and_upload_media`].
/// User-initiated: the user dropped the files onto Buzz.
#[tauri::command]
pub async fn upload_dropped_media(
paths: Vec<String>,
progress_id: Option<String>,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<BlobDescriptor>, String> {
if paths.is_empty() {
return Ok(Vec::new());
}
if paths.len() > MAX_DROPPED_FILES {
return Err(format!("too many dropped files (max {MAX_DROPPED_FILES})"));
}

let mut descriptors = Vec::with_capacity(paths.len());
for raw in paths {
let path = validate_dropped_path(&raw)?;
let progress = progress_id.clone().map(|id| (app.clone(), id));
let descriptor = process_picked_path(path, &state, false, progress).await?;
descriptors.push(descriptor);
}
Ok(descriptors)
}

pub(super) async fn upload_media_bytes_inner(
data: Vec<u8>,
filename: Option<String>,
Expand Down Expand Up @@ -982,6 +1030,29 @@ mod tests {
));
}

#[test]
fn test_validate_dropped_path_rejects_relative_and_empty() {
assert!(validate_dropped_path("").is_err());
assert!(validate_dropped_path("photo.png").is_err());
assert!(validate_dropped_path("./photo.png").is_err());
}

#[test]
fn test_validate_dropped_path_rejects_missing_and_directory() {
assert!(validate_dropped_path("/no/such/buzz-drop-test-file").is_err());
assert!(validate_dropped_path(std::env::temp_dir().to_str().unwrap()).is_err());
}

#[test]
fn test_validate_dropped_path_accepts_regular_file() {
let path =
std::env::temp_dir().join(format!("buzz-drop-validate-{}", uuid::Uuid::new_v4()));
std::fs::write(&path, b"ok").unwrap();
let result = validate_dropped_path(path.to_str().unwrap());
let _ = std::fs::remove_file(&path);
assert!(result.is_ok());
}

#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,7 @@ pub fn run() {
upload_media,
pick_and_upload_media,
pick_and_upload_image,
upload_dropped_media,
upload_media_bytes,
upload_media_bytes_raw,
cancel_media_upload,
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/app/useAppShellLifecycleEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export function useAppShellLifecycleEffects({
// Composer's onDrop fires first (React synthetic before window bubble).
React.useEffect(() => {
function preventNavigation(e: DragEvent) {
if (e.dataTransfer?.types.includes("Files")) {
const types = Array.from(e.dataTransfer?.types ?? []);
if (types.includes("Files") || types.includes("text/uri-list")) {
e.preventDefault();
}
}
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { ChevronDown } from "lucide-react";
import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import {
extractDroppedFilePayload,
isOsFileDrag,
} from "@/features/messages/lib/droppedFiles";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
Expand Down Expand Up @@ -352,13 +356,28 @@ export function ForumComposer({
// ── Media paste ─────────────────────────────────────────────────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
const handleDropRef = React.useRef(media.handleDrop);
handleDropRef.current = media.handleDrop;

React.useEffect(() => {
if (!richText.editor) return;

richText.editor.setOptions({
editorProps: {
...richText.editor.options.editorProps,
handleDrop: (_view, event) => {
const dragEvent = event as DragEvent;
const payload = extractDroppedFilePayload(dragEvent.dataTransfer);
const isFileDrop =
isOsFileDrag(dragEvent.dataTransfer) ||
payload.files.length > 0 ||
payload.paths.length > 0;
if (!isFileDrop) return false;
dragEvent.preventDefault();
dragEvent.stopPropagation();
void handleDropRef.current(dragEvent);
return true;
},
handlePaste: (_view, event) => {
const items = Array.from(event.clipboardData?.items ?? []);
// Any actual file pastes as an attachment; text/string items fall
Expand Down
143 changes: 143 additions & 0 deletions desktop/src/features/messages/lib/droppedFiles.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
basenameFromPath,
extractDroppedFilePayload,
extractPathsFromText,
fileUriOrAbsolutePath,
isOsFileDrag,
looksLikeFileName,
} from "./droppedFiles.ts";

test("fileUriOrAbsolutePath accepts unix absolute paths", () => {
assert.equal(
fileUriOrAbsolutePath("/home/me/Pictures/cat.png"),
"/home/me/Pictures/cat.png",
);
});

test("fileUriOrAbsolutePath accepts file:// URIs", () => {
assert.equal(
fileUriOrAbsolutePath("file:///home/me/Pictures/cat.png"),
"/home/me/Pictures/cat.png",
);
});

test("fileUriOrAbsolutePath decodes percent-encoded names", () => {
assert.equal(
fileUriOrAbsolutePath("file:///home/me/My%20Photos/cat%20hat.png"),
"/home/me/My Photos/cat hat.png",
);
});

test("fileUriOrAbsolutePath strips the extra slash on Windows file URIs", () => {
assert.equal(
fileUriOrAbsolutePath("file:///C:/Users/me/Desktop/shot.jpg"),
"C:/Users/me/Desktop/shot.jpg",
);
});

test("fileUriOrAbsolutePath accepts Windows drive and UNC paths", () => {
assert.equal(
fileUriOrAbsolutePath(String.raw`C:\Users\me\a.png`),
String.raw`C:\Users\me\a.png`,
);
assert.equal(
fileUriOrAbsolutePath(String.raw`\\nas\share\photo.jpg`),
String.raw`\\nas\share\photo.jpg`,
);
});

test("fileUriOrAbsolutePath rejects http(s) and relative paths", () => {
assert.equal(fileUriOrAbsolutePath("https://example.com/a.png"), null);
assert.equal(fileUriOrAbsolutePath("http://example.com/a.png"), null);
assert.equal(fileUriOrAbsolutePath("photos/cat.png"), null);
assert.equal(fileUriOrAbsolutePath("file:not-a-url"), null);
assert.equal(fileUriOrAbsolutePath(""), null);
});

test("extractPathsFromText skips uri-list comments and blanks", () => {
const text = [
"# comment",
"file:///tmp/a.png",
"",
"/tmp/b.jpg",
"https://example.com/c.png",
].join("\n");
assert.deepEqual(extractPathsFromText(text), ["/tmp/a.png", "/tmp/b.jpg"]);
});

test("extractPathsFromText deduplicates", () => {
assert.deepEqual(extractPathsFromText("/tmp/a.png\n/tmp/a.png\n"), [
"/tmp/a.png",
]);
});

test("isOsFileDrag is true for Files and uri-list, not plain text", () => {
assert.equal(isOsFileDrag({ types: ["Files"] }), true);
assert.equal(isOsFileDrag({ types: ["text/uri-list", "text/plain"] }), true);
assert.equal(isOsFileDrag({ types: ["text/plain"] }), false);
assert.equal(isOsFileDrag({ types: ["text/html"] }), false);
assert.equal(isOsFileDrag(null), false);
});

test("extractDroppedFilePayload prefers File objects over path text", () => {
const file = { name: "a.png" };
const data = {
files: [file],
getData: () => "file:///tmp/ignored.png",
};
assert.deepEqual(extractDroppedFilePayload(data), {
files: [file],
paths: [],
});
});

test("extractDroppedFilePayload recovers paths when files is empty", () => {
const data = {
files: [],
types: ["text/uri-list", "text/plain"],
getData: (type) =>
type === "text/uri-list"
? "file:///tmp/a.png\nfile:///tmp/b.jpg"
: "/tmp/a.png",
};
assert.deepEqual(extractDroppedFilePayload(data), {
files: [],
paths: ["/tmp/a.png", "/tmp/b.jpg"],
});
});

test("extractDroppedFilePayload is empty for ordinary text drags", () => {
const data = {
files: [],
types: ["text/plain"],
getData: () => "hello from another app",
};
assert.deepEqual(extractDroppedFilePayload(data), { files: [], paths: [] });
});

test("extractDroppedFilePayload ignores absolute paths without a file extension", () => {
const data = {
files: [],
types: ["text/plain"],
getData: () => "/usr/bin/env python\n/etc/passwd",
};
assert.deepEqual(extractDroppedFilePayload(data), { files: [], paths: [] });
});

test("looksLikeFileName requires a basename with an extension", () => {
assert.equal(looksLikeFileName("/tmp/photo.png"), true);
assert.equal(looksLikeFileName("/etc/passwd"), false);
assert.equal(looksLikeFileName("/usr/bin/env python"), false);
});

test("basenameFromPath handles unix and windows separators", () => {
assert.equal(basenameFromPath("/tmp/dir/photo.png"), "photo.png");
assert.equal(
basenameFromPath(String.raw`C:\Users\me\photo.png`),
"photo.png",
);
assert.equal(basenameFromPath("/"), "file");
});
Loading
Loading