Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
55 changes: 54 additions & 1 deletion desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,15 @@ pub async fn upload_media(

/// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff →
/// transcode-or-passthrough → MIME validation → upload).
///
/// When `images_only` is set, the file is rejected **before upload** if it is
/// not an image (videos and non-image files error out; HEIC/HEIF still
/// transcode to JPEG, which is an image). This keeps discarded/non-image
/// files from ever leaving the client on image-only surfaces.
async fn process_picked_path(
path: std::path::PathBuf,
state: &State<'_, AppState>,
images_only: bool,
) -> 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.
Expand All @@ -325,6 +331,9 @@ async fn process_picked_path(
let n = file.read(&mut header).map_err(|e| e.to_string())?;

if is_video_file(&header[..n]) {
if images_only {
return Err("Please choose an image file.".to_string());
}
// ffmpeg needs a path, not an fd. Resolve the fd's real path
// so we pass the actual inode's location, not the original
// (potentially swapped) pathname. Same pattern as upload_media.
Expand Down Expand Up @@ -357,6 +366,12 @@ async fn process_picked_path(

let mime = detect_and_validate_mime(&body)?;

// Image-only surfaces (e.g. "Send feedback"): reject anything that didn't
// sniff as an image, BEFORE the upload leaves the client.
if images_only && !mime.starts_with("image/") {
return Err("Please choose an image file.".to_string());
}

// 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, None).await?;
Expand Down Expand Up @@ -414,13 +429,51 @@ pub async fn pick_and_upload_media(
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?;
let descriptor = process_picked_path(path, &state, false).await?;
descriptors.push(descriptor);
}

Ok(descriptors)
}

/// Open a native single-file dialog constrained to images, read the picked
/// file, and upload it — rejecting anything that doesn't sniff as an image
/// **before** the bytes leave the client.
///
/// This is the secure path for image-only surfaces (e.g. the "Send feedback"
/// attachment). Unlike [`pick_and_upload_media`], the dialog is filtered to
/// common image extensions and `process_picked_path` runs with
/// `images_only = true`, so a user who bypasses the extension filter still
/// can't upload a non-image (videos and other files error out during MIME
/// validation, before `do_upload`). Returns `None` when the user cancels.
#[tauri::command]
pub async fn pick_and_upload_image(
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(
"Images",
&["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "bmp"],
)
.pick_file(move |path| {
let _ = tx.send(path);
});

let file_path = match rx.await.map_err(|_| "dialog cancelled".to_string())? {
Some(path) => path,
None => return Ok(None),
};

let path = file_path.as_path().ok_or("invalid path")?.to_path_buf();
let descriptor = process_picked_path(path, &state, true).await?;
Ok(Some(descriptor))
}

/// Upload raw bytes directly (for paste and drag-drop).
///
/// The renderer already has the bytes in memory from the clipboard/drag event.
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 @@ -776,6 +776,7 @@ pub fn run() {
show_native_notification,
upload_media,
pick_and_upload_media,
pick_and_upload_image,
upload_media_bytes,
download_image,
download_file,
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { useArchiveSync } from "@/features/local-archive/archiveSyncManager";
import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed";
import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed";
import { useProfileQuery } from "@/features/profile/hooks";
import { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController";
import {
DEFAULT_SETTINGS_SECTION,
type SettingsSection,
Expand Down Expand Up @@ -110,6 +111,7 @@ export function AppShell() {
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false);
const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false);
const mainInsetRef = React.useRef<HTMLElement>(null);
const location = useLocation();
Expand Down Expand Up @@ -767,6 +769,7 @@ export function AppShell() {
onNewMessage={handleOpenNewDm}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddCommunity={() => setIsAddCommunityOpen(true)}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={communitiesHook.removeCommunity}
onSwitchCommunity={handleSwitchCommunity}
Expand Down Expand Up @@ -924,6 +927,10 @@ export function AppShell() {
void goChannel(channelId);
}}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
</SidebarProvider>
</div>

Expand Down
12 changes: 12 additions & 0 deletions desktop/src/features/presence/lib/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,15 @@ export function getPresenceDotClassName(status: PresenceStatus) {
return "bg-muted-foreground/35";
}
}

// Chip styling for the presence pill (colored fill + matching text, no dot).
export function getPresenceChipClassName(status: PresenceStatus) {
switch (status) {
case "online":
return "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400";
case "away":
return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
case "offline":
return "bg-muted-foreground/15 text-muted-foreground";
}
}
Loading