mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
feat(desktop): multi-select in the paperclip file picker (#551)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
@@ -469,46 +469,12 @@ fn transcode_and_extract_poster(
|
||||
Ok((video_bytes?, poster_bytes))
|
||||
}
|
||||
|
||||
/// Open a native file dialog, read the selected file, and upload it.
|
||||
///
|
||||
/// All file I/O happens in trusted Rust — the renderer never touches the
|
||||
/// filesystem. This is the secure path for the 📎 paperclip button.
|
||||
///
|
||||
/// **Residual TOCTOU note:** The Tauri dialog plugin returns a pathname, not
|
||||
/// a file handle, so there is a small race window between dialog return and
|
||||
/// `File::open()`. This is an inherent limitation of the OS file-picker API
|
||||
/// (no platform exposes a handle/bookmark from the open-file dialog in a way
|
||||
/// the Tauri plugin surfaces). The risk is bounded: the attacker must be local
|
||||
/// and must win a race against an immediate open. Server-side content validation
|
||||
/// (MIME, image decode, size caps) provides defense-in-depth.
|
||||
#[tauri::command]
|
||||
pub async fn pick_and_upload_media(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<BlobDescriptor>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter(
|
||||
"Media",
|
||||
&[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "mp4", "mov", "mkv", "webm", "avi",
|
||||
],
|
||||
)
|
||||
.pick_file(move |path| {
|
||||
let _ = tx.send(path);
|
||||
});
|
||||
|
||||
let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?;
|
||||
let file_path = match selected {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let path = file_path.as_path().ok_or("invalid path")?.to_path_buf();
|
||||
|
||||
/// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff →
|
||||
/// transcode-or-passthrough → MIME validation → upload).
|
||||
async fn process_picked_path(
|
||||
path: std::path::PathBuf,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<BlobDescriptor, String> {
|
||||
// Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a
|
||||
// local attacker from swapping the file between dialog return and read.
|
||||
let mut file = std::fs::File::open(&path).map_err(|e| e.to_string())?;
|
||||
@@ -549,16 +515,67 @@ pub async fn pick_and_upload_media(
|
||||
|
||||
// Upload video first, then poster (best-effort). If poster upload fails,
|
||||
// the video descriptor is returned without an image field.
|
||||
let mut descriptor = do_upload(body, &mime, &state).await?;
|
||||
let mut descriptor = do_upload(body, &mime, state).await?;
|
||||
|
||||
if let Some(poster) = poster_bytes {
|
||||
match do_upload(poster, "image/jpeg", &state).await {
|
||||
match do_upload(poster, "image/jpeg", state).await {
|
||||
Ok(poster_desc) => descriptor.image = Some(poster_desc.url),
|
||||
Err(e) => eprintln!("sprout-desktop: poster upload failed (non-fatal): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(descriptor))
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
/// Open a native file dialog (multi-select), read each selected file, and
|
||||
/// upload it. Returns the resulting `BlobDescriptor` list — empty when the
|
||||
/// user cancels.
|
||||
///
|
||||
/// All file I/O happens in trusted Rust — the renderer never touches the
|
||||
/// filesystem. This is the secure path for the 📎 paperclip button.
|
||||
///
|
||||
/// **Residual TOCTOU note:** The Tauri dialog plugin returns pathnames, not
|
||||
/// file handles, so there is a small race window between dialog return and
|
||||
/// `File::open()` — an inherent limit of the OS file-picker API. The risk is
|
||||
/// bounded (local attacker winning a race against an immediate open) and
|
||||
/// server-side content validation (MIME, image decode, size caps) is the
|
||||
/// defense in depth.
|
||||
///
|
||||
/// Uploads run sequentially; on first failure, prior uploads are not
|
||||
/// rolled back (they're already content-addressed on the relay).
|
||||
#[tauri::command]
|
||||
pub async fn pick_and_upload_media(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<BlobDescriptor>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter(
|
||||
"Media",
|
||||
&[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "mp4", "mov", "mkv", "webm", "avi",
|
||||
],
|
||||
)
|
||||
.pick_files(move |paths| {
|
||||
let _ = tx.send(paths);
|
||||
});
|
||||
|
||||
let file_paths = match rx.await.map_err(|_| "dialog cancelled".to_string())? {
|
||||
Some(paths) => paths,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut descriptors = Vec::with_capacity(file_paths.len());
|
||||
for file_path in file_paths {
|
||||
let path = file_path.as_path().ok_or("invalid path")?.to_path_buf();
|
||||
let descriptor = process_picked_path(path, &state).await?;
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
|
||||
Ok(descriptors)
|
||||
}
|
||||
|
||||
/// Upload raw bytes directly (for paste and drag-drop).
|
||||
|
||||
@@ -112,18 +112,22 @@ export function useMediaUpload() {
|
||||
}, []);
|
||||
|
||||
const handlePaperclip = React.useCallback(async () => {
|
||||
// Hold a single pending tick while the native picker is open + uploads
|
||||
// run in Rust. We don't know the file count until the dialog returns,
|
||||
// and uploads are already complete by then, so we just append each
|
||||
// descriptor when we get them back.
|
||||
setUploadingCount((c) => c + 1);
|
||||
try {
|
||||
const descriptor = await pickAndUploadMedia();
|
||||
if (descriptor) {
|
||||
onUploaded(descriptor);
|
||||
} else {
|
||||
setUploadingCount((c) => Math.max(0, c - 1));
|
||||
const descriptors = await pickAndUploadMedia();
|
||||
setUploadingCount((c) => Math.max(0, c - 1));
|
||||
for (const descriptor of descriptors) {
|
||||
nextSlotRef.current += 1;
|
||||
setImetaSlots((prev) => [...prev, descriptor]);
|
||||
}
|
||||
} catch (err) {
|
||||
onUploadError(err);
|
||||
}
|
||||
}, [onUploaded, onUploadError]);
|
||||
}, [onUploadError]);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
async (event: React.DragEvent<HTMLFormElement>) => {
|
||||
|
||||
@@ -738,8 +738,8 @@ export async function uploadMedia(
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickAndUploadMedia(): Promise<BlobDescriptor | null> {
|
||||
return invokeTauri<BlobDescriptor | null>("pick_and_upload_media", {});
|
||||
export async function pickAndUploadMedia(): Promise<BlobDescriptor[]> {
|
||||
return invokeTauri<BlobDescriptor[]>("pick_and_upload_media", {});
|
||||
}
|
||||
|
||||
export async function uploadMediaBytes(
|
||||
|
||||
Reference in New Issue
Block a user