feat(media): support arbitrary file types with download cards (#810)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-01 12:57:04 -07:00
committed by GitHub
co-authored by Brain
parent 247ac52391
commit 2b052eb465
31 changed files with 1596 additions and 543 deletions
+10
View File
@@ -4,6 +4,10 @@ fn default_max_video_bytes() -> u64 {
524_288_000 // 500 MB
}
fn default_max_file_bytes() -> u64 {
104_857_600 // 100 MB
}
/// Configuration for media storage (S3/MinIO).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct MediaConfig {
@@ -22,6 +26,9 @@ pub struct MediaConfig {
/// Maximum upload size for video files (bytes). Default: 500 MB.
#[serde(default = "default_max_video_bytes")]
pub max_video_bytes: u64,
/// Maximum upload size for generic (non-image, non-video) files (bytes). Default: 100 MB.
#[serde(default = "default_max_file_bytes")]
pub max_file_bytes: u64,
/// Public base URL for media URLs in BlobDescriptor (must include `/media` path).
pub public_base_url: String,
/// Server authority for BUD-11 server tag validation.
@@ -56,6 +63,9 @@ impl MediaConfig {
if self.max_video_bytes == 0 {
return Err("max_video_bytes must be > 0".to_string());
}
if self.max_file_bytes == 0 {
return Err("max_file_bytes must be > 0".to_string());
}
Ok(())
}
}
+2 -2
View File
@@ -15,5 +15,5 @@ pub use config::MediaConfig;
pub use error::MediaError;
pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage};
pub use types::BlobDescriptor;
pub use upload::{process_upload, process_video_upload};
pub use validation::{validate_video_file, VideoMeta};
pub use upload::{process_file_upload, process_upload, process_video_upload};
pub use validation::{serve_inline, validate_video_file, VideoMeta};
+138 -17
View File
@@ -10,27 +10,47 @@ use crate::error::MediaError;
use crate::storage::{BlobMeta, MediaStorage};
use crate::thumbnail::generate_image_metadata_sync;
use crate::types::BlobDescriptor;
use crate::validation::{mime_to_ext, validate_content, validate_video_file};
use crate::validation::{
mime_to_ext, validate_content, validate_file_content, validate_video_file,
};
/// Process an upload end-to-end: validate, store, thumbnail, return descriptor.
/// Shared buffered-upload pipeline for the image and generic-file paths.
///
/// This is the image path — body is already fully buffered in RAM. Do NOT use
/// this for video uploads; use [`process_video_upload`] instead.
pub async fn process_upload(
/// Both paths are identical except for two steps, which are injected:
/// - `validate`: a CPU-bound check (run inside `spawn_blocking`) that returns
/// the `(mime, ext)` pair for the body. Images derive `ext` from the MIME;
/// generic files get both from the deny-list validator.
/// - `store_metadata`: stores the sidecar (and any derived artifacts such as a
/// thumbnail) and returns the resulting [`BlobMeta`]. Images run the full
/// image-metadata pipeline; generic files write a minimal sidecar. It
/// receives the already-computed `(sha256, ext, mime, uploaded_at)` so no
/// work is repeated.
///
/// Everything else — hash, Blossom auth (10-minute window), content-addressed
/// key, the both-exist idempotency short-circuit, blob store, orphan-blob
/// handling, and descriptor build — is common. The streaming video path stays
/// separate (see [`process_video_upload`]) because it never buffers in RAM.
async fn process_buffered_upload<V, M, Fut>(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body: Bytes,
) -> Result<BlobDescriptor, MediaError> {
// CPU-bound: validate content, compute hash, verify auth
validate: V,
store_metadata: M,
) -> Result<BlobDescriptor, MediaError>
where
V: FnOnce(&Bytes, &MediaConfig) -> Result<(String, String), MediaError> + Send + 'static,
M: FnOnce(MetadataInput) -> Fut,
Fut: std::future::Future<Output = Result<BlobMeta, MediaError>>,
{
// CPU-bound: validate content, compute hash, verify auth.
let auth = auth_event.clone();
let bytes = body.clone();
let cfg = config.clone();
let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> {
let mime = validate_content(&bytes, &cfg)?;
let (mime, ext) = validate(&bytes, &cfg)?;
let sha256 = hex::encode(Sha256::digest(&bytes));
let ext = mime_to_ext(&mime).to_string();
// Images: 10-minute auth window is plenty.
// Buffered uploads (image + file): 10-minute auth window is plenty.
verify_blossom_upload_auth(&auth, &sha256, cfg.server_domain.as_deref(), 600)?;
Ok((mime, sha256, ext))
})
@@ -38,10 +58,10 @@ pub async fn process_upload(
.map_err(|_| MediaError::Internal)??;
let key = format!("{sha256}.{ext}");
let meta_key = format!("_meta/{sha256}.json"); // used in idempotency check below
let meta_key = format!("_meta/{sha256}.json");
// Idempotent: check BOTH sidecar AND blob exist before short-circuiting.
// If sidecar exists but blob is missing, fall through to re-upload.
// Idempotent: short-circuit only if BOTH sidecar and blob exist. If the
// sidecar exists but the blob is missing, fall through to re-upload.
let sidecar_exists = storage.head(&meta_key).await?;
let blob_exists = storage.head(&key).await?;
if sidecar_exists && blob_exists {
@@ -60,7 +80,7 @@ pub async fn process_upload(
// Compute uploaded_at once — single source of truth for sidecar and response.
let uploaded_at = chrono::Utc::now().timestamp();
// Store blob first, then generate metadata.
// Store blob first, then metadata.
// On failure we intentionally do NOT delete the orphan blob — concurrent
// uploads of the same hash could race and delete a blob that another
// request is about to reference via its sidecar. Orphan blobs are
@@ -69,9 +89,16 @@ pub async fn process_upload(
// matching sidecar after a grace period.
storage.put(&key, &body, &mime).await?;
match generate_and_store_metadata(storage, config, &sha256, &ext, &mime, &body, uploaded_at)
.await
{
let meta_result = store_metadata(MetadataInput {
sha256: sha256.clone(),
ext: ext.clone(),
mime: mime.clone(),
body: body.clone(),
uploaded_at,
})
.await;
match meta_result {
Ok(meta) => Ok(build_descriptor(
config,
&sha256,
@@ -88,6 +115,99 @@ pub async fn process_upload(
}
}
/// Inputs handed to a buffered-upload metadata builder, after the shared
/// pipeline has already validated, hashed, and stored the blob. Owned so the
/// builder's future doesn't borrow the pipeline's locals; `body` is a `Bytes`
/// handle, so cloning it is a refcount bump, not a copy.
struct MetadataInput {
sha256: String,
ext: String,
mime: String,
body: Bytes,
uploaded_at: i64,
}
/// Process an upload end-to-end: validate, store, thumbnail, return descriptor.
///
/// This is the image path — body is already fully buffered in RAM. Do NOT use
/// this for video uploads; use [`process_video_upload`] instead.
pub async fn process_upload(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body: Bytes,
) -> Result<BlobDescriptor, MediaError> {
process_buffered_upload(
storage,
config,
auth_event,
body,
|bytes, cfg| {
let mime = validate_content(bytes, cfg)?;
let ext = mime_to_ext(&mime).to_string();
Ok((mime, ext))
},
|input| async move {
generate_and_store_metadata(
storage,
config,
&input.sha256,
&input.ext,
&input.mime,
&input.body,
input.uploaded_at,
)
.await
},
)
.await
}
/// Process a generic (non-image, non-video) file upload end-to-end.
///
/// This is the catch-all attachment path: documents, archives, audio, text,
/// data — anything that isn't a previewable image or an H.264 MP4. The body is
/// fully buffered in RAM (bounded by `config.max_file_bytes` at the transport
/// layer), validated against the deny-list + size cap, stored, and recorded in
/// a minimal sidecar. No thumbnail, no dimensions, no duration.
///
/// The resulting blob is served with `Content-Disposition: attachment`, so the
/// client always downloads it rather than rendering it inline.
pub async fn process_file_upload(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body: Bytes,
) -> Result<BlobDescriptor, MediaError> {
process_buffered_upload(
storage,
config,
auth_event,
body,
|bytes, cfg| validate_file_content(bytes, cfg),
|input| async move {
// Minimal sidecar — no thumbnail/dim/blurhash/duration for generic files.
let meta = BlobMeta {
dim: String::new(),
blurhash: String::new(),
thumb_url: String::new(),
size: input.body.len() as u64,
ext: input.ext,
mime_type: input.mime,
uploaded_at: input.uploaded_at,
duration_secs: None,
};
let meta_key = format!("_meta/{}.json", input.sha256);
let meta_json = serde_json::to_vec(&meta)?;
storage
.put(&meta_key, &meta_json, "application/json")
.await?;
Ok(meta)
},
)
.await
}
/// Process a video upload end-to-end using a streaming pipeline.
///
/// Unlike [`process_upload`], this function:
@@ -356,6 +476,7 @@ mod tests {
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_file_bytes: 104_857_600,
public_base_url: "https://media.example.com".to_string(),
server_domain: None,
}
+208
View File
@@ -14,6 +14,143 @@ use crate::error::MediaError;
/// `video/mp4` and `validate_content()` rejects it here.
const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
/// MIME types blocked from the generic file-upload path.
///
/// These are the formats a browser (or the desktop webview) will *execute* or
/// *render as active content* if it ever reaches them with the wrong response
/// headers. We serve generic files with `Content-Disposition: attachment` +
/// `X-Content-Type-Options: nosniff` + `CSP: default-src 'none'`, which already
/// neutralises them — this allowlist-of-denials is defence in depth, so a future
/// header regression can't turn an uploaded blob into a stored-XSS vector.
///
/// HTML, JS, and SVG are the classic stored-XSS carriers. Native executables are
/// blocked because there's no legitimate reason to host them inline in chat and
/// they're a malware-distribution risk.
const BLOCKED_FILE_MIME_TYPES: &[&str] = &[
// Active web content — stored-XSS vectors.
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"application/javascript",
"text/javascript",
// Native executables / installers.
"application/x-msdownload", // .exe / .dll
"application/x-executable", // ELF
"application/vnd.microsoft.portable-executable",
"application/x-mach-binary", // Mach-O
"application/x-sharedlib",
"application/x-elf",
"application/x-msi",
"application/vnd.android.package-archive", // .apk
"application/x-apple-diskimage", // .dmg
];
/// Map a sniffed MIME type to a file extension for the generic file path.
///
/// Covers the common document, archive, audio, and data formats `infer`
/// recognises. Returns `None` for MIME types we don't have a canonical
/// extension for — the caller falls back to `bin`.
fn file_mime_to_ext(mime: &str) -> Option<&'static str> {
let ext = match mime {
// Documents
"application/pdf" => "pdf",
"application/msword" => "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
"application/vnd.ms-excel" => "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
"application/vnd.ms-powerpoint" => "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx",
"application/vnd.oasis.opendocument.text" => "odt",
"application/vnd.oasis.opendocument.spreadsheet" => "ods",
"application/vnd.oasis.opendocument.presentation" => "odp",
"application/rtf" => "rtf",
"application/epub+zip" => "epub",
// Archives
"application/zip" => "zip",
"application/gzip" => "gz",
"application/x-tar" => "tar",
"application/x-7z-compressed" => "7z",
"application/x-rar-compressed" | "application/vnd.rar" => "rar",
"application/x-bzip2" => "bz2",
"application/x-xz" => "xz",
"application/zstd" => "zst",
// Audio
"audio/mpeg" => "mp3",
"audio/mp4" | "audio/m4a" | "audio/x-m4a" => "m4a",
"audio/flac" | "audio/x-flac" => "flac",
"audio/wav" | "audio/x-wav" => "wav",
"audio/ogg" => "ogg",
"audio/aac" => "aac",
"audio/opus" => "opus",
// Other media containers (served as downloads, not transcoded)
"video/quicktime" => "mov",
"video/webm" => "webm",
"video/x-matroska" => "mkv",
// Data / text
"application/json" => "json",
"text/csv" => "csv",
"text/plain" => "txt",
_ => return None,
};
Some(ext)
}
/// Validate uploaded bytes for the **generic file** upload path.
///
/// This is the catch-all path for non-image, non-video attachments (documents,
/// archives, audio, text, data). It enforces three things:
/// 1. A size cap (`config.max_file_bytes`).
/// 2. A *deny* list — known active-content and executable MIME types are
/// rejected even though safe headers already neutralise them.
/// 3. Magic-byte sniffing where possible.
///
/// Files with no detectable signature (plain text, CSV, source code, JSON —
/// none of which have magic bytes) are accepted as `application/octet-stream`.
/// They are always served as downloads, so an un-sniffable file can never
/// execute in the app.
///
/// Returns `(mime, ext)`.
pub fn validate_file_content(
bytes: &[u8],
config: &MediaConfig,
) -> Result<(String, String), MediaError> {
// 1. Size cap.
if bytes.len() as u64 > config.max_file_bytes {
return Err(MediaError::FileTooLarge {
size: bytes.len() as u64,
max: config.max_file_bytes,
});
}
// 2. Sniff. `None` means no magic signature (text/csv/json/source) — that's
// fine for the generic path; treat as opaque binary served as a download.
match infer::get(bytes) {
Some(kind) => {
let mime = kind.mime_type().to_string();
// 3. Deny dangerous active-content / executable types.
if BLOCKED_FILE_MIME_TYPES.contains(&mime.as_str()) {
return Err(MediaError::DisallowedContentType(mime));
}
let ext = file_mime_to_ext(&mime)
.map(str::to_string)
.unwrap_or_else(|| kind.extension().to_string());
Ok((mime, ext))
}
None => Ok(("application/octet-stream".to_string(), "bin".to_string())),
}
}
/// Whether a stored blob should be served inline (rendered in the client) or as
/// an attachment (forced download).
///
/// Images and video are previewed inline by the renderer; everything else is a
/// generic file card with a download action, so it serves as an attachment.
/// PDF is intentionally *not* inline yet — inline PDF preview is a planned
/// fast-follow; until the renderer handles it, force download like any other file.
pub fn serve_inline(mime: &str) -> bool {
mime.starts_with("image/") || mime.starts_with("video/")
}
/// Metadata extracted from a validated MP4 file.
#[derive(Debug, Clone)]
pub struct VideoMeta {
@@ -282,6 +419,7 @@ mod tests {
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_file_bytes: 104_857_600,
public_base_url: String::new(),
server_domain: None,
}
@@ -1139,4 +1277,74 @@ mod tests {
"expected ResolutionTooHigh, got {result:?}"
);
}
// --- Generic file path tests ---
/// Minimal PDF header — infer detects `application/pdf` from `%PDF`.
const TINY_PDF: &[u8] = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n%%EOF";
/// Minimal ZIP header — infer detects `application/zip` from `PK\x03\x04`.
const TINY_ZIP: &[u8] = &[
0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[test]
fn test_validate_file_pdf_accepted() {
let config = test_config();
let (mime, ext) = validate_file_content(TINY_PDF, &config).unwrap();
assert_eq!(mime, "application/pdf");
assert_eq!(ext, "pdf");
}
#[test]
fn test_validate_file_zip_accepted() {
let config = test_config();
let (mime, ext) = validate_file_content(TINY_ZIP, &config).unwrap();
assert_eq!(mime, "application/zip");
assert_eq!(ext, "zip");
}
#[test]
fn test_validate_file_plaintext_accepted_as_octet_stream() {
// Plain text has no magic bytes — infer returns None. The generic path
// accepts it as opaque binary served as a download (the common Slack
// case: .txt, .csv, .md, source code).
let config = test_config();
let (mime, ext) = validate_file_content(b"hello, this is a text file\n", &config).unwrap();
assert_eq!(mime, "application/octet-stream");
assert_eq!(ext, "bin");
}
#[test]
fn test_validate_file_html_rejected() {
// HTML is a stored-XSS carrier — blocked even though headers neutralise it.
let config = test_config();
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
let result = validate_file_content(html, &config);
assert!(
matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"),
"expected DisallowedContentType(text/html), got {result:?}"
);
}
#[test]
fn test_validate_file_too_large_rejected() {
let mut config = test_config();
config.max_file_bytes = 10;
let result = validate_file_content(TINY_PDF, &config);
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
}
#[test]
fn test_serve_inline() {
assert!(serve_inline("image/jpeg"));
assert!(serve_inline("image/png"));
assert!(serve_inline("video/mp4"));
// Generic files force download.
assert!(!serve_inline("application/pdf"));
assert!(!serve_inline("application/zip"));
assert!(!serve_inline("application/octet-stream"));
assert!(!serve_inline("audio/mpeg"));
assert!(!serve_inline("text/plain"));
}
}
+96 -24
View File
@@ -146,18 +146,42 @@ pub async fn upload_blob(
)
.await?
} else {
// Image path: collect body to bytes (bounded by transport-layer limit).
let max = state.config.media.max_image_bytes;
// Non-video path: buffer the body (bounded by the larger of the image
// and generic-file caps), then decide image-vs-generic by sniffed MIME.
// Images go through the thumbnailing pipeline; everything else (docs,
// archives, audio, text, data) takes the generic file path and is
// served as a download.
let max = state
.config
.media
.max_image_bytes
.max(state.config.media.max_file_bytes);
let bytes = axum::body::to_bytes(body, max as usize)
.await
.map_err(|_| MediaError::FileTooLarge { size: 0, max })?;
sprout_media::process_upload(
&state.media_storage,
&state.config.media,
&auth.auth_event,
bytes,
)
.await?
let is_image = matches!(
infer::get(&bytes).map(|t| t.mime_type()),
Some("image/jpeg" | "image/png" | "image/gif" | "image/webp")
);
if is_image {
sprout_media::process_upload(
&state.media_storage,
&state.config.media,
&auth.auth_event,
bytes,
)
.await?
} else {
sprout_media::process_file_upload(
&state.media_storage,
&state.config.media,
&auth.auth_event,
bytes,
)
.await?
}
};
// Normalize MIME to a known set to bound label cardinality.
@@ -197,18 +221,29 @@ pub async fn upload_blob(
// ── Serve ─────────────────────────────────────────────────────────────────────
/// Validate that sha256_ext is a safe path segment.
/// Whether a path-segment extension is a safe token.
///
/// The sidecar's `ext` field is the *authoritative* extension — the serve and
/// resolve paths always compare the requested ext against it. This check is a
/// cheap structural gate to reject obviously hostile path segments (traversal,
/// overlong, non-alphanumeric) before any storage lookup. Accepts 18 lowercase
/// alphanumeric chars, which covers every extension the generic file path emits
/// (jpg, png, mp4, pdf, docx, xlsx, tar, 7z, mp3, flac, json, bin, …).
pub(crate) fn is_safe_ext(ext: &str) -> bool {
!ext.is_empty() && ext.len() <= 8 && ext.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9'))
}
/// Validate that `sha256_ext` is a safe path segment.
///
/// Accepted forms (max 3 segments):
/// - `{sha256}` — bare 64-char lowercase hex
/// - `{sha256}.{ext}` — hash + extension
/// - `{sha256}.thumb.jpg` — hash + thumb variant (always JPEG)
///
/// Where `{ext}` ∈ {"jpg", "png", "gif", "webp"} for primary blobs (uploads canonicalize to .jpg, not .jpeg).
/// `{ext}` must be a safe token (see [`is_safe_ext`]); the sidecar comparison
/// downstream enforces the actual canonical extension.
/// Rejects path traversal, leading underscores, and any non-hex first segment.
fn validate_media_path(sha256_ext: &str) -> Result<(), MediaError> {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"];
let segments: Vec<&str> = sha256_ext.split('.').collect();
// 13 segments only (hash, optional thumb, optional ext)
@@ -227,7 +262,7 @@ fn validate_media_path(sha256_ext: &str) -> Result<(), MediaError> {
1 => {} // bare hash — ok
2 => {
// {hash}.{ext}
if !ALLOWED_EXTS.contains(&segments[1]) {
if !is_safe_ext(segments[1]) {
return Err(MediaError::NotFound);
}
}
@@ -303,6 +338,16 @@ pub async fn get_blob(
sidecar_mime
};
// Images and video render inline; generic files force download. This is the
// primary defence for non-previewable types — combined with `nosniff` and
// `CSP: default-src 'none'`, an attachment disposition prevents an uploaded
// file from ever executing or rendering as active content in the client.
let disposition = if sprout_media::serve_inline(&content_type) {
"inline"
} else {
"attachment"
};
let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?;
// Parse optional Range header.
@@ -330,7 +375,7 @@ pub async fn get_blob(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &content_type)
.header(header::CONTENT_LENGTH, total.to_string())
.header(header::CONTENT_DISPOSITION, "inline")
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::CONTENT_SECURITY_POLICY, "default-src 'none'")
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
@@ -375,7 +420,7 @@ pub async fn get_blob(
.header(header::CONTENT_TYPE, &content_type)
.header(header::CONTENT_RANGE, content_range)
.header(header::CONTENT_LENGTH, chunk.len().to_string())
.header(header::CONTENT_DISPOSITION, "inline")
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::CONTENT_SECURITY_POLICY, "default-src 'none'")
@@ -498,14 +543,12 @@ pub async fn head_blob(
/// - `sha256.ext` → used as-is (already validated by `validate_media_path`)
/// - `sha256` (no dot) → read sidecar to get extension, return `sha256.ext`
///
/// Sidecar-derived extensions are validated against the allowlist to prevent
/// Sidecar-derived extensions are validated as safe tokens to prevent
/// object-key confusion if sidecar data is ever tampered with.
async fn resolve_s3_key(
storage: &sprout_media::MediaStorage,
sha256_ext: &str,
) -> Result<String, MediaError> {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"];
if sha256_ext.contains('.') {
Ok(sha256_ext.to_string())
} else {
@@ -514,7 +557,7 @@ async fn resolve_s3_key(
.await
.map_err(|_| MediaError::NotFound)?;
// Validate sidecar ext — never trust storage as authoritative for path construction
if !ALLOWED_EXTS.contains(&sidecar.ext.as_str()) {
if !is_safe_ext(&sidecar.ext) {
return Err(MediaError::NotFound);
}
Ok(format!("{}.{}", sha256_ext, sidecar.ext))
@@ -648,10 +691,39 @@ mod tests {
}
#[test]
fn test_validate_media_path_rejects_bad_ext() {
assert!(validate_media_path(&format!("{VALID_HASH}.svg")).is_err());
assert!(validate_media_path(&format!("{VALID_HASH}.exe")).is_err());
assert!(validate_media_path(&format!("{VALID_HASH}.pdf")).is_err());
fn test_validate_media_path_accepts_generic_exts() {
// Path validation now accepts any safe ext token — the deny-list for
// dangerous *content* lives in the upload validator, not here. The
// sidecar ext comparison is the authoritative check at serve time.
assert!(validate_media_path(&format!("{VALID_HASH}.pdf")).is_ok());
assert!(validate_media_path(&format!("{VALID_HASH}.docx")).is_ok());
assert!(validate_media_path(&format!("{VALID_HASH}.zip")).is_ok());
assert!(validate_media_path(&format!("{VALID_HASH}.mp3")).is_ok());
assert!(validate_media_path(&format!("{VALID_HASH}.bin")).is_ok());
}
#[test]
fn test_validate_media_path_rejects_malformed_ext() {
// Reject ext tokens that aren't safe: uppercase, too long, special chars.
assert!(validate_media_path(&format!("{VALID_HASH}.PDF")).is_err());
assert!(validate_media_path(&format!("{VALID_HASH}.toolongext")).is_err());
// 3-segment paths are only valid as the `.thumb.jpg` variant; a
// hash.tar.gz form is rejected (compound extensions aren't a thing here —
// the canonical ext is a single token like `gz`).
assert!(validate_media_path(&format!("{VALID_HASH}.tar.gz")).is_err());
}
#[test]
fn test_is_safe_ext() {
assert!(is_safe_ext("jpg"));
assert!(is_safe_ext("docx"));
assert!(is_safe_ext("7z"));
assert!(is_safe_ext("bin"));
assert!(!is_safe_ext("")); // empty
assert!(!is_safe_ext("PDF")); // uppercase
assert!(!is_safe_ext("ta r")); // space
assert!(!is_safe_ext("toolongext")); // > 8 chars
assert!(!is_safe_ext("../etc")); // traversal chars
}
#[test]
+4
View File
@@ -249,6 +249,10 @@ impl Config {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 1024 * 1024),
max_file_bytes: std::env::var("SPROUT_MAX_FILE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100 * 1024 * 1024),
public_base_url: std::env::var("SPROUT_MEDIA_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000/media".to_string()),
server_domain: std::env::var("SPROUT_MEDIA_SERVER_DOMAIN")
+132 -12
View File
@@ -9,12 +9,18 @@ use sprout_media::validation::mime_to_ext;
pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result<(), String> {
const ALLOWED_IMETA_KEYS: &[&str] = &[
"url", "m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback", "duration",
"bitrate", "image",
"bitrate", "image", "filename",
];
const SINGLETON_KEYS: &[&str] = &[
"url", "m", "x", "size", "dim", "blurhash", "thumb", "alt", "duration", "bitrate", "image",
"filename",
];
const ALLOWED_MIME: &[&str] = &[
// Previewable media MIME types — these get the strict url-extension
// consistency check below (their ext is derived from the MIME). Generic
// files carry arbitrary MIME types whose ext can't be derived from the MIME
// alone, so their consistency is enforced against the sidecar in
// `verify_imeta_blobs` rather than here.
const MEDIA_MIME: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
@@ -63,11 +69,13 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
has_url = true;
}
"m" => {
if !ALLOWED_MIME.contains(&value) {
return Err(
"imeta m must be a supported MIME type (image/jpeg, image/png, image/gif, image/webp, video/mp4)"
.into(),
);
// Accept any well-formed `type/subtype` MIME token. The
// authoritative gate is `verify_imeta_blobs`, which requires
// `m` to equal the stored sidecar MIME — and a sidecar only
// exists for content that passed the upload validator's
// deny-list. So a blocked type can never reach a valid imeta.
if !is_well_formed_mime(value) {
return Err("imeta m must be a valid MIME type".into());
}
m_value = value.to_string();
has_m = true;
@@ -128,6 +136,23 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
);
}
}
"filename" => {
// Original filename for the file-card label. Bounded length;
// no path separators or control chars (it's display-only and
// must never influence storage keys, which are content-addressed).
if value.is_empty() || value.len() > 255 {
return Err("imeta filename must be 1255 chars".into());
}
if value.contains('/')
|| value.contains('\\')
|| value.chars().any(|c| c.is_control())
{
return Err(
"imeta filename must not contain path separators or control characters"
.into(),
);
}
}
_ => {}
}
}
@@ -155,10 +180,17 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
}
}
if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) {
let expected_ext = mime_to_ext(&m_value);
if ext_in_url != expected_ext {
return Err("imeta url extension does not match m".into());
if MEDIA_MIME.contains(&m_value.as_str()) {
// Previewable media: the ext is derivable from the MIME, so
// enforce exact equality.
let expected_ext = mime_to_ext(&m_value);
if ext_in_url != expected_ext {
return Err("imeta url extension does not match m".into());
}
}
// Generic files: ext can't be derived from the MIME. The sidecar
// cross-check in `verify_imeta_blobs` enforces that the URL's ext
// (and hash, size, MIME) match the stored blob.
}
if !thumb_value.is_empty() {
if let Some(thumb_hash) = extract_hash_from_media_url(&thumb_value) {
@@ -299,6 +331,22 @@ pub async fn verify_imeta_blobs(
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Whether a string is a well-formed `type/subtype` MIME token.
///
/// Structural check only — does not enforce a known type. The authoritative
/// content gate is the upload validator's deny-list plus the sidecar MIME
/// cross-check in `verify_imeta_blobs`. Rejects empties, missing slash,
/// whitespace, and control characters.
fn is_well_formed_mime(mime: &str) -> bool {
let Some((ty, sub)) = mime.split_once('/') else {
return false;
};
!ty.is_empty()
&& !sub.is_empty()
&& mime.len() <= 255
&& !mime.chars().any(|c| c.is_whitespace() || c.is_control())
}
/// Extract the 64-char hex hash from a `/media/{hash}.{ext}` URL.
fn extract_hash_from_media_url(url: &str) -> Option<&str> {
let after = url.rsplit("/media/").next()?;
@@ -323,7 +371,11 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> {
/// Validate that a URL references a valid local media blob path.
fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"];
// A safe extension token: 18 lowercase alphanumeric chars. Covers media
// (jpg, png, mp4) and every generic file ext (pdf, docx, zip, mp3, bin, …).
// The blob's authoritative ext lives in the sidecar; this is a structural
// gate. Shared with the serve/resolve paths so the predicate can't drift.
use crate::api::media::is_safe_ext;
let path_after_media = if let Some(rest) = url.strip_prefix("/media/") {
rest
@@ -351,7 +403,7 @@ fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
let ext = segments[1];
hash.len() == 64
&& hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
&& ALLOWED_EXTS.contains(&ext)
&& is_safe_ext(ext)
}
3 => {
let hash = segments[0];
@@ -415,4 +467,72 @@ mod tests {
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("url hash does not match x"), "{err}");
}
#[test]
fn test_imeta_generic_file_with_filename_passes() {
// Generic file attachment: non-media MIME, arbitrary ext, filename label.
// The url-ext-vs-MIME equality check is skipped for non-media MIMEs
// (the sidecar cross-check in verify_imeta_blobs enforces correctness).
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.pdf"),
"m application/pdf".into(),
format!("x {HASH}"),
"size 2048".into(),
"filename Q3-budget.pdf".into(),
];
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
}
#[test]
fn test_imeta_octet_stream_passes() {
// Un-sniffable text/data files upload as octet-stream with a .bin ext.
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.bin"),
"m application/octet-stream".into(),
format!("x {HASH}"),
"size 512".into(),
"filename notes.txt".into(),
];
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
}
#[test]
fn test_imeta_filename_rejects_path_separators() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.pdf"),
"m application/pdf".into(),
format!("x {HASH}"),
"size 2048".into(),
"filename ../../etc/passwd".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("filename"), "{err}");
}
#[test]
fn test_imeta_rejects_malformed_mime() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.bin"),
"m not-a-mime".into(),
format!("x {HASH}"),
"size 512".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("valid MIME"), "{err}");
}
#[test]
fn test_is_well_formed_mime() {
assert!(is_well_formed_mime("application/pdf"));
assert!(is_well_formed_mime("application/octet-stream"));
assert!(is_well_formed_mime("audio/mpeg"));
assert!(!is_well_formed_mime("notamime"));
assert!(!is_well_formed_mime("/pdf"));
assert!(!is_well_formed_mime("application/"));
assert!(!is_well_formed_mime("application/ pdf")); // whitespace
}
}
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig({
"**/channels.spec.ts",
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",
"**/file-attachment.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
"**/workflows.spec.ts",
+88 -289
View File
@@ -5,9 +5,10 @@ use sha2::{Digest, Sha256};
use tauri::State;
use crate::app_state::AppState;
use crate::managed_agents::resolve_command;
use crate::relay::relay_api_base_url_with_override;
use super::media_transcode::{is_video_file, transcode_and_extract_poster};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobDescriptor {
pub url: String,
@@ -24,6 +25,10 @@ pub struct BlobDescriptor {
/// NIP-71 poster frame URL. `None` for non-video blobs or if extraction failed.
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
/// Original filename, for the generic file-card label. Captured client-side
/// (the relay is content-addressed and never learns it). `None` for media.
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -101,20 +106,51 @@ fn fd_real_path(_file: &std::fs::File) -> Result<std::path::PathBuf, String> {
Err("fd_real_path not supported on this platform".to_string())
}
/// MIME allowlist — must match the server's allowed types.
const ALLOWED_MIME: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
/// MIME types blocked from upload — mirrors the server's generic-file deny-list.
///
/// Active-content XSS carriers and native executables. Everything else (images,
/// video, documents, archives, audio, text, data) is accepted; un-sniffable
/// files fall back to `application/octet-stream` and are served as downloads.
const BLOCKED_MIME: &[&str] = &[
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"application/javascript",
"text/javascript",
"application/x-msdownload",
"application/x-executable",
"application/vnd.microsoft.portable-executable",
"application/x-mach-binary",
"application/x-sharedlib",
"application/x-elf",
"application/x-msi",
"application/vnd.android.package-archive",
"application/x-apple-diskimage",
];
/// Sanitize a filename for use as a display label in the imeta `filename` field.
///
/// Strips any directory components (keeps only the final path segment), removes
/// control characters, and bounds length to 255. Mirrors the relay's filename
/// validation so a sanitized name always passes ingest. Returns a fallback when
/// the result would be empty.
pub(crate) fn sanitize_filename(name: &str) -> String {
// Keep only the final path segment — defend against `../` and absolute paths
// regardless of separator style.
let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(255).collect();
if cleaned.is_empty() {
"file".to_string()
} else {
cleaned
}
}
pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
let mime = infer::get(body)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if !ALLOWED_MIME.contains(&mime.as_str()) {
if BLOCKED_MIME.contains(&mime.as_str()) {
return Err(format!("unsupported file type: {mime}"));
}
Ok(mime)
@@ -233,251 +269,6 @@ pub async fn upload_media(
do_upload(body, &mime, &state).await
}
// ── Video transcode helpers ──────────────────────────────────────────────────
/// Locate ffmpeg using the same discovery logic as managed agents
/// (login shell PATH, /opt/homebrew/bin, /usr/local/bin, etc.).
/// Returns the resolved absolute path on success.
fn find_ffmpeg() -> Result<std::path::PathBuf, String> {
let ffmpeg_path = resolve_command("ffmpeg").ok_or_else(|| {
"ffmpeg is required for video uploads but was not found.\n\n\
Install it:\n \
macOS: brew install ffmpeg\n \
Linux: sudo apt install ffmpeg\n \
Windows: winget install ffmpeg"
.to_string()
})?;
match std::process::Command::new(&ffmpeg_path)
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) if s.success() => Ok(ffmpeg_path),
Ok(_) => Err(
"ffmpeg was found but returned an error — it may be broken or misconfigured"
.to_string(),
),
Err(e) => Err(format!("failed to check for ffmpeg: {e}")),
}
}
/// Detect if a file is a video based on magic bytes.
fn is_video_file(buf: &[u8]) -> bool {
infer::get(buf).is_some_and(|t| t.mime_type().starts_with("video/"))
}
/// Maximum wall-clock time for an ffmpeg transcode before we kill it.
/// 10 minutes is generous for any reasonable video; pathological inputs
/// (crafted to cause exponential decode time) get killed instead of
/// blocking a Tokio worker thread indefinitely.
const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
/// Run an ffmpeg command with a wall-clock timeout.
///
/// Spawns the child process, polls `try_wait()` every 500ms, and kills it
/// if the deadline is exceeded. Returns the same `Output` as `Command::output()`.
///
/// **IMPORTANT**: callers MUST pass `-loglevel error` (or `quiet`) to ffmpeg.
/// This function reads stderr only after the child exits. If ffmpeg writes
/// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB),
/// the child blocks on write() and never exits — causing a false timeout.
/// `-loglevel error` suppresses progress spam, keeping stderr small.
fn run_ffmpeg_with_timeout(
cmd: &mut std::process::Command,
timeout: std::time::Duration,
) -> Result<std::process::Output, String> {
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn ffmpeg: {e}"))?;
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
// Process exited — collect output.
let stdout = child.stdout.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
let stderr = child.stderr.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
// Still running — check deadline.
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait(); // reap zombie
return Err(format!("ffmpeg timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(e) => return Err(format!("failed to wait on ffmpeg: {e}")),
}
}
}
/// Transcode any video file to H.264/AAC/MP4/fast-start via ffmpeg.
///
/// Always re-encodes — handles HEVC, VP9, ProRes, non-faststart MP4, 10-bit,
/// wrong pixel format, MOV containers, etc. Output is guaranteed to pass the
/// relay's `validate_video_file()`.
///
/// Returns the path to a temp file. Caller must clean up.
fn transcode_to_mp4(
source: &std::path::Path,
ffmpeg: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
// UUID-based temp path — unique across concurrent uploads.
let output =
std::env::temp_dir().join(format!("sprout-transcode-{}.mp4", uuid::Uuid::new_v4()));
let result = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock
.arg("-i")
.arg(source) // OsStr — handles non-UTF-8 paths on Unix
.args([
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
FFMPEG_TIMEOUT,
)?;
if !result.status.success() {
let _ = std::fs::remove_file(&output);
let stderr = String::from_utf8_lossy(&result.stderr);
let detail = stderr
.lines()
.rev()
.find(|l| !l.is_empty() && !l.starts_with(" "))
.unwrap_or("unknown error");
return Err(format!("Video conversion failed: {detail}"));
}
Ok(output)
}
/// Extract a single JPEG poster frame from a transcoded MP4 via ffmpeg.
///
/// Seeks to 1 second (avoids black leader frames), falls back to first frame
/// for videos shorter than 1 second. Output is scaled to 640px wide with even
/// dimensions. Returns the path to a temp JPEG. Caller must clean up.
///
/// Best-effort: returns `Err` on failure — callers should log and continue
/// without a poster rather than failing the entire video upload.
fn extract_poster_frame(
mp4_path: &std::path::Path,
ffmpeg: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
let output = std::env::temp_dir().join(format!("sprout-poster-{}.jpg", uuid::Uuid::new_v4()));
// Poster extraction is a single-frame decode — 30s is generous.
let poster_timeout = std::time::Duration::from_secs(30);
// Try seeking to 1s first (avoids black first frames from fade-ins).
let result = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
.arg("-ss")
.arg("1")
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
// If seek to 1s failed (video shorter than 1s), retry from first frame.
if !result.status.success()
|| !output.exists()
|| std::fs::metadata(&output).map_or(true, |m| m.len() == 0)
{
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
eprintln!("sprout-desktop: poster seek-to-1s failed, trying first frame: {stderr}");
}
let _ = std::fs::remove_file(&output);
let fallback = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
if !fallback.status.success() || !output.exists() {
let stderr = String::from_utf8_lossy(&fallback.stderr);
eprintln!("sprout-desktop: poster frame extraction failed: {stderr}");
let _ = std::fs::remove_file(&output);
return Err("ffmpeg could not extract a poster frame".to_string());
}
}
Ok(output)
}
/// Transcode video and extract poster frame. Returns (video_bytes, Option<poster_bytes>).
///
/// Poster extraction is best-effort — if it fails, returns `None` for the poster
/// and the video bytes are still valid. All temp files are cleaned up.
fn transcode_and_extract_poster(
source: &std::path::Path,
) -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
let ffmpeg_path = find_ffmpeg()?;
let transcoded = transcode_to_mp4(source, &ffmpeg_path)?;
// Extract poster from the transcoded file (not the original — guarantees decodability).
let poster_bytes = match extract_poster_frame(&transcoded, &ffmpeg_path) {
Ok(poster_path) => {
let bytes = std::fs::read(&poster_path).ok();
let _ = std::fs::remove_file(&poster_path);
bytes
}
Err(e) => {
eprintln!("sprout-desktop: poster extraction failed (non-fatal): {e}");
None
}
};
let video_bytes =
std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}"));
let _ = std::fs::remove_file(&transcoded);
Ok((video_bytes?, poster_bytes))
}
/// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff →
/// transcode-or-passthrough → MIME validation → upload).
async fn process_picked_path(
@@ -533,6 +324,16 @@ async fn process_picked_path(
}
}
// Generic files (non-image, non-video) carry their original filename so the
// client can render a file card with a real label. Media is identified by
// its preview, so no filename is attached.
if !mime.starts_with("image/") && !mime.starts_with("video/") {
descriptor.filename = path
.file_name()
.and_then(|n| n.to_str())
.map(sanitize_filename);
}
Ok(descriptor)
}
@@ -560,17 +361,11 @@ pub async fn pick_and_upload_media(
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);
});
// No filter — accept any file. The deny-list (active content + executables)
// and size caps are enforced by `detect_and_validate_mime` and the relay.
app.dialog().file().pick_files(move |paths| {
let _ = tx.send(paths);
});
let file_paths = match rx.await.map_err(|_| "dialog cancelled".to_string())? {
Some(paths) => paths,
@@ -595,6 +390,7 @@ pub async fn pick_and_upload_media(
#[tauri::command]
pub async fn upload_media_bytes(
data: Vec<u8>,
filename: Option<String>,
state: State<'_, AppState>,
) -> Result<BlobDescriptor, String> {
if data.is_empty() {
@@ -634,6 +430,12 @@ pub async fn upload_media_bytes(
}
}
// Attach the original filename for generic files (drag/paste supply it from
// the JS File object). Media identifies itself by its preview, so skip it.
if !mime.starts_with("image/") && !mime.starts_with("video/") {
descriptor.filename = filename.as_deref().map(sanitize_filename);
}
Ok(descriptor)
}
@@ -693,36 +495,33 @@ mod tests {
}
#[test]
fn test_detect_and_validate_mime_rejects_text() {
fn test_detect_and_validate_mime_accepts_text_as_octet_stream() {
// Plain text has no magic bytes — infer returns None, so it's accepted
// as opaque binary (served as a download). This is the common Slack case.
let text = b"hello world";
assert!(detect_and_validate_mime(text).is_err());
assert_eq!(
detect_and_validate_mime(text).unwrap(),
"application/octet-stream"
);
}
#[test]
fn test_is_video_file_mp4() {
// Minimal ftyp box (MP4 magic bytes)
let ftyp: &[u8] = &[
0x00, 0x00, 0x00, 0x14, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm', 0x00, 0x00,
0x00, 0x00, b'i', b's', b'o', b'm',
];
assert!(is_video_file(ftyp));
fn test_detect_and_validate_mime_rejects_html() {
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
assert!(detect_and_validate_mime(html).is_err());
}
#[test]
fn test_is_video_file_jpeg_is_not_video() {
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
assert!(!is_video_file(&jpeg));
}
#[test]
fn test_is_video_file_empty() {
assert!(!is_video_file(&[]));
}
#[test]
fn test_find_ffmpeg_runs() {
// This test verifies the function doesn't panic.
// It may pass or fail depending on whether ffmpeg is installed.
let _ = find_ffmpeg();
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("report.pdf"), "report.pdf");
// Strips directory components and traversal.
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt");
assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx");
// Empty / separator-only falls back.
assert_eq!(sanitize_filename(""), "file");
assert_eq!(sanitize_filename("/"), "file");
// Control chars removed.
assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt");
}
}
@@ -0,0 +1,284 @@
//! Video transcoding and poster-frame extraction via ffmpeg.
//!
//! Split out of `media.rs` to keep that file under the desktop line-size
//! limit. These helpers are used by the upload pipeline to normalize any
//! video to H.264/AAC/MP4/fast-start (guaranteed to pass the relay's
//! `validate_video_file()`) and to produce a JPEG poster frame.
use crate::managed_agents::resolve_command;
/// Locate ffmpeg using the same discovery logic as managed agents
/// (login shell PATH, /opt/homebrew/bin, /usr/local/bin, etc.).
/// Returns the resolved absolute path on success.
pub(super) fn find_ffmpeg() -> Result<std::path::PathBuf, String> {
let ffmpeg_path = resolve_command("ffmpeg").ok_or_else(|| {
"ffmpeg is required for video uploads but was not found.\n\n\
Install it:\n \
macOS: brew install ffmpeg\n \
Linux: sudo apt install ffmpeg\n \
Windows: winget install ffmpeg"
.to_string()
})?;
match std::process::Command::new(&ffmpeg_path)
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) if s.success() => Ok(ffmpeg_path),
Ok(_) => Err(
"ffmpeg was found but returned an error — it may be broken or misconfigured"
.to_string(),
),
Err(e) => Err(format!("failed to check for ffmpeg: {e}")),
}
}
/// Detect if a file is a video based on magic bytes.
pub(super) fn is_video_file(buf: &[u8]) -> bool {
infer::get(buf).is_some_and(|t| t.mime_type().starts_with("video/"))
}
/// Maximum wall-clock time for an ffmpeg transcode before we kill it.
/// 10 minutes is generous for any reasonable video; pathological inputs
/// (crafted to cause exponential decode time) get killed instead of
/// blocking a Tokio worker thread indefinitely.
const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
/// Run an ffmpeg command with a wall-clock timeout.
///
/// Spawns the child process, polls `try_wait()` every 500ms, and kills it
/// if the deadline is exceeded. Returns the same `Output` as `Command::output()`.
///
/// **IMPORTANT**: callers MUST pass `-loglevel error` (or `quiet`) to ffmpeg.
/// This function reads stderr only after the child exits. If ffmpeg writes
/// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB),
/// the child blocks on write() and never exits — causing a false timeout.
/// `-loglevel error` suppresses progress spam, keeping stderr small.
pub(super) fn run_ffmpeg_with_timeout(
cmd: &mut std::process::Command,
timeout: std::time::Duration,
) -> Result<std::process::Output, String> {
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn ffmpeg: {e}"))?;
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
// Process exited — collect output.
let stdout = child.stdout.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
let stderr = child.stderr.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
// Still running — check deadline.
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait(); // reap zombie
return Err(format!("ffmpeg timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(e) => return Err(format!("failed to wait on ffmpeg: {e}")),
}
}
}
/// Transcode any video file to H.264/AAC/MP4/fast-start via ffmpeg.
///
/// Always re-encodes — handles HEVC, VP9, ProRes, non-faststart MP4, 10-bit,
/// wrong pixel format, MOV containers, etc. Output is guaranteed to pass the
/// relay's `validate_video_file()`.
///
/// Returns the path to a temp file. Caller must clean up.
pub(super) fn transcode_to_mp4(
source: &std::path::Path,
ffmpeg: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
// UUID-based temp path — unique across concurrent uploads.
let output =
std::env::temp_dir().join(format!("sprout-transcode-{}.mp4", uuid::Uuid::new_v4()));
let result = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock
.arg("-i")
.arg(source) // OsStr — handles non-UTF-8 paths on Unix
.args([
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
FFMPEG_TIMEOUT,
)?;
if !result.status.success() {
let _ = std::fs::remove_file(&output);
let stderr = String::from_utf8_lossy(&result.stderr);
let detail = stderr
.lines()
.rev()
.find(|l| !l.is_empty() && !l.starts_with(" "))
.unwrap_or("unknown error");
return Err(format!("Video conversion failed: {detail}"));
}
Ok(output)
}
/// Extract a single JPEG poster frame from a transcoded MP4 via ffmpeg.
///
/// Seeks to 1 second (avoids black leader frames), falls back to first frame
/// for videos shorter than 1 second. Output is scaled to 640px wide with even
/// dimensions. Returns the path to a temp JPEG. Caller must clean up.
///
/// Best-effort: returns `Err` on failure — callers should log and continue
/// without a poster rather than failing the entire video upload.
pub(super) fn extract_poster_frame(
mp4_path: &std::path::Path,
ffmpeg: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
let output = std::env::temp_dir().join(format!("sprout-poster-{}.jpg", uuid::Uuid::new_v4()));
// Poster extraction is a single-frame decode — 30s is generous.
let poster_timeout = std::time::Duration::from_secs(30);
// Try seeking to 1s first (avoids black first frames from fade-ins).
let result = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
.arg("-ss")
.arg("1")
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
// If seek to 1s failed (video shorter than 1s), retry from first frame.
if !result.status.success()
|| !output.exists()
|| std::fs::metadata(&output).map_or(true, |m| m.len() == 0)
{
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
eprintln!("sprout-desktop: poster seek-to-1s failed, trying first frame: {stderr}");
}
let _ = std::fs::remove_file(&output);
let fallback = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
if !fallback.status.success() || !output.exists() {
let stderr = String::from_utf8_lossy(&fallback.stderr);
eprintln!("sprout-desktop: poster frame extraction failed: {stderr}");
let _ = std::fs::remove_file(&output);
return Err("ffmpeg could not extract a poster frame".to_string());
}
}
Ok(output)
}
/// Transcode video and extract poster frame. Returns (video_bytes, Option<poster_bytes>).
///
/// Poster extraction is best-effort — if it fails, returns `None` for the poster
/// and the video bytes are still valid. All temp files are cleaned up.
pub(super) fn transcode_and_extract_poster(
source: &std::path::Path,
) -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
let ffmpeg_path = find_ffmpeg()?;
let transcoded = transcode_to_mp4(source, &ffmpeg_path)?;
// Extract poster from the transcoded file (not the original — guarantees decodability).
let poster_bytes = match extract_poster_frame(&transcoded, &ffmpeg_path) {
Ok(poster_path) => {
let bytes = std::fs::read(&poster_path).ok();
let _ = std::fs::remove_file(&poster_path);
bytes
}
Err(e) => {
eprintln!("sprout-desktop: poster extraction failed (non-fatal): {e}");
None
}
};
let video_bytes =
std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}"));
let _ = std::fs::remove_file(&transcoded);
Ok((video_bytes?, poster_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_video_file_mp4() {
// Minimal ftyp box (MP4 magic bytes)
let ftyp: &[u8] = &[
0x00, 0x00, 0x00, 0x14, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm', 0x00, 0x00,
0x00, 0x00, b'i', b's', b'o', b'm',
];
assert!(is_video_file(ftyp));
}
#[test]
fn test_is_video_file_jpeg_is_not_video() {
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
assert!(!is_video_file(&jpeg));
}
#[test]
fn test_is_video_file_empty() {
assert!(!is_video_file(&[]));
}
#[test]
fn test_find_ffmpeg_runs() {
// This test verifies the function doesn't panic.
// It may pass or fail depending on whether ffmpeg is installed.
let _ = find_ffmpeg();
}
}
+1
View File
@@ -11,6 +11,7 @@ mod identity;
mod identity_archive;
mod media;
mod media_download;
mod media_transcode;
mod messages;
pub mod pairing;
mod personas;
+13 -28
View File
@@ -1,12 +1,10 @@
import * as React from "react";
import { EditorContent } from "@tiptap/react";
import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import {
ALLOWED_MEDIA_TYPES,
useMediaUpload,
} from "@/features/messages/lib/useMediaUpload";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
hasMentionClipboardHtml,
@@ -189,27 +187,14 @@ export function ForumComposer({
const pubkeys = mentions.extractMentionPubkeys(trimmed);
const mediaTags =
currentPendingImeta.length > 0
? currentPendingImeta.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
`x ${d.sha256}`,
`size ${d.size}`,
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
])
: undefined;
let finalContent = trimmed;
for (const d of currentPendingImeta) {
const isVideo = d.type.startsWith("video/");
finalContent += isVideo ? `\n![video](${d.url})` : `\n![image](${d.url})`;
}
// Reuse the shared send-path builder so forum/notes posts emit the same
// body + imeta as chat: generic files become `[filename](url)` links with a
// `filename` imeta tag (FileCard renderer), images/video stay inline. Send
// semantics use `undefined` for "no attachments" (no imeta tags emitted).
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
currentPendingImeta,
);
// Save draft state so we can restore on failure.
const savedContent = contentRef.current;
@@ -299,9 +284,9 @@ export function ForumComposer({
...richText.editor.options.editorProps,
handlePaste: (_view, event) => {
const items = Array.from(event.clipboardData?.items ?? []);
const mediaItem = items.find((item) =>
ALLOWED_MEDIA_TYPES.includes(item.type),
);
// Any actual file pastes as an attachment; text/string items fall
// through to the handlers below.
const mediaItem = items.find((item) => item.kind === "file");
if (mediaItem) {
const file = mediaItem.getAsFile();
if (file) {
@@ -8,6 +8,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
@@ -109,6 +110,7 @@ export function ForumPostCard({
<Markdown
compact
content={previewContent}
imetaByUrl={parseImetaTags(post.tags)}
mentionNames={mentionNames}
/>
</div>
@@ -10,6 +10,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
@@ -107,6 +108,7 @@ function ReplyRow({
channelNames={channelNames}
compact
content={reply.content}
imetaByUrl={parseImetaTags(reply.tags)}
mentionNames={replyMentionNames}
/>
</div>
@@ -245,6 +247,7 @@ export function ForumThreadPanel({
<Markdown
channelNames={channelNames}
content={post.content}
imetaByUrl={parseImetaTags(post.tags)}
mentionNames={postMentionNames}
/>
</div>
@@ -1,112 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
// ── Inlined pure functions from imetaMediaMarkdown.ts ─────────────────
// Inlined to avoid importing from .ts files (no TS loader in node:test).
// Same pattern as markdown.test.mjs / useMediaUpload.test.mjs.
const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/;
function stripImetaMediaLines(body, imetaMedia) {
if (imetaMedia.length === 0) return body;
const urls = new Set(imetaMedia.map((m) => m.url));
const lines = body.split("\n");
let end = lines.length;
while (end > 0) {
const line = lines[end - 1];
if (line.trim() === "") {
end -= 1;
continue;
}
const match = line.match(MEDIA_LINE_RE);
if (match && urls.has(match[1])) {
end -= 1;
continue;
}
break;
}
return lines.slice(0, end).join("\n").replace(/\s+$/, "");
}
function formatImetaMediaLine({ url, type }) {
const isVideo = type.startsWith("video/");
return isVideo ? `\n![video](${url})` : `\n![image](${url})`;
}
function buildImetaTags(imetaMedia) {
return imetaMedia.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
...(d.sha256 ? [`x ${d.sha256}`] : []),
...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []),
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
]);
}
function buildOutgoingMessage(body, pendingImeta) {
let content = body;
for (const d of pendingImeta) content += formatImetaMediaLine(d);
const mediaTags =
pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined;
return { content, mediaTags };
}
// Mirror of `parseImetaTags` + `imetaMediaFromTags` so the projection's
// type/x/size/dim/blurhash/thumb/duration/image fields can be tested without
// a TS loader.
function parseImetaTagsInline(tags) {
const map = new Map();
for (const tag of tags) {
if (tag[0] !== "imeta") continue;
const entry = {};
for (const part of tag.slice(1)) {
const i = part.indexOf(" ");
if (i === -1) continue;
const key = part.slice(0, i);
const val = part.slice(i + 1);
if (key === "url") entry.url = val;
else if (key === "m") entry.m = val;
else if (key === "x") entry.x = val;
else if (key === "size") entry.size = parseInt(val, 10);
else if (key === "dim") entry.dim = val;
else if (key === "blurhash") entry.blurhash = val;
else if (key === "thumb") entry.thumb = val;
else if (key === "duration") entry.duration = parseFloat(val);
else if (key === "image") entry.image = val;
}
if (entry.url) map.set(entry.url, entry);
}
return map;
}
function imetaMediaFromTags(tags) {
if (!tags || tags.length === 0) return [];
const entries = parseImetaTagsInline(tags);
const out = [];
for (const e of entries.values()) {
if (!e.url) continue;
out.push({
url: e.url,
type: e.m ?? "image/jpeg",
sha256: e.x ?? "",
size: e.size ?? 0,
uploaded: 0,
...(e.dim ? { dim: e.dim } : {}),
...(e.blurhash ? { blurhash: e.blurhash } : {}),
...(e.thumb ? { thumb: e.thumb } : {}),
...(e.duration != null ? { duration: e.duration } : {}),
...(e.image ? { image: e.image } : {}),
});
}
return out;
}
// ── stripImetaMediaLines ──────────────────────────────────────────────
// Import the REAL implementations (the runner strips TS types via
// test-loader.mjs). Earlier this file inlined stale copies of these functions,
// which silently drifted from source — the inlined formatImetaMediaLine had no
// generic-file branch at all, so it could never catch a regression there.
// Importing the real module closes that blind spot.
import {
buildImetaTags,
buildOutgoingMessage,
formatImetaMediaLine,
imetaMediaFromTags,
stripImetaMediaLines,
} from "./imetaMediaMarkdown.ts";
test("strip: removes trailing image line whose URL is in imetaMedia", () => {
const body = "Look at this\n![image](https://blossom/abc.png)";
@@ -180,6 +86,40 @@ test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suff
);
});
test("formatImetaMediaLine: generic mime → [filename](url) link", () => {
assert.equal(
formatImetaMediaLine({
url: "https://b/blob",
type: "application/pdf",
filename: "report.pdf",
}),
"\n[report.pdf](https://b/blob)",
);
});
test("formatImetaMediaLine: escapes markdown brackets/backslash in filename", () => {
// `a].pdf` would otherwise close the link label early and break the FileCard.
assert.equal(
formatImetaMediaLine({
url: "https://b/blob",
type: "application/zip",
filename: "a]b[c\\d.zip",
}),
"\n[a\\]b\\[c\\\\d.zip](https://b/blob)",
);
});
test("strip: removes an escaped-bracket generic file line on edit", () => {
// The escaped label must still be recognised by FILE_LINE_RE so the body is
// cleaned in edit mode (regression guard for the FILE_LINE_RE escape support).
const url = "https://b/blob";
const body = `note${formatImetaMediaLine({ url, type: "application/pdf", filename: "a].pdf" })}`;
const stripped = stripImetaMediaLines(body, [
{ url, type: "application/pdf" },
]);
assert.equal(stripped, "note");
});
// ── imetaMediaFromTags (full BlobDescriptor projection) ───────────────
test("imetaMediaFromTags: empty / undefined", () => {
@@ -65,6 +65,7 @@ export function imetaMediaFromTags(
...(entry.thumb ? { thumb: entry.thumb } : {}),
...(entry.duration != null ? { duration: entry.duration } : {}),
...(entry.image ? { image: entry.image } : {}),
...(entry.filename ? { filename: entry.filename } : {}),
});
}
return out;
@@ -96,10 +97,18 @@ export function buildImetaTags(
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
...(d.filename ? [`filename ${d.filename}`] : []),
]);
}
const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/;
/**
* Matches a generic file-attachment line `[label](url)` (no leading `!`, so it's
* a link not an image). The label can contain spaces and backslash-escaped
* brackets (e.g. `a\]`); the URL must be paren- and space-free. Used to strip
* file attachments from the body in edit mode.
*/
const FILE_LINE_RE = /^\[(?:\\.|[^\]\\])*\]\(([^)\s]+)\)\s*$/;
/**
* Remove trailing `![image|video](url)` lines whose URL matches an entry in
@@ -123,7 +132,7 @@ export function stripImetaMediaLines(
end -= 1;
continue;
}
const match = line.match(MEDIA_LINE_RE);
const match = line.match(MEDIA_LINE_RE) ?? line.match(FILE_LINE_RE);
if (match && urls.has(match[1])) {
end -= 1;
continue;
@@ -135,12 +144,27 @@ export function stripImetaMediaLines(
}
/**
* Format a single imeta entry as a leading-newline markdown line. Mime-driven
* so the alt label is correct regardless of URL suffix.
* Format a single imeta entry as a leading-newline markdown line.
*
* Images and video use `![image|video](url)` so the renderer draws them inline.
* Generic files use a plain `[filename](url)` link the renderer recognises the
* href as a local media blob with a non-media MIME and upgrades it to a file
* card. Mime-driven so the form is correct regardless of URL suffix.
*/
export function formatImetaMediaLine({ url, type }: ImetaMedia): string {
const isVideo = type.startsWith("video/");
return isVideo ? `\n![video](${url})` : `\n![image](${url})`;
export function formatImetaMediaLine({
url,
type,
filename,
}: ImetaMedia): string {
if (type.startsWith("video/")) return `\n![video](${url})`;
if (type.startsWith("image/")) return `\n![image](${url})`;
// Generic file: plain link, label is the original filename (fallback to url tail).
const label = filename || url.split("/").pop() || "file";
// Escape markdown link-label metacharacters so filenames containing `[`, `]`,
// or `\` (e.g. `a].pdf`) still render as a FileCard with the correct label
// rather than breaking the link or mangling the visible text.
const escaped = label.replace(/[\\[\]]/g, "\\$&");
return `\n[${escaped}](${url})`;
}
/**
@@ -9,6 +9,7 @@ export type ImetaEntry = {
thumb?: string;
duration?: number;
image?: string;
filename?: string;
};
export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
@@ -52,6 +53,9 @@ export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
case "image":
entry.image = val;
break;
case "filename":
entry.filename = val;
break;
}
}
if (entry.url) map.set(entry.url, entry as ImetaEntry);
@@ -6,18 +6,6 @@ import {
uploadMediaBytes,
} from "@/shared/api/tauri";
export const ALLOWED_MEDIA_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
"video/quicktime",
"video/x-matroska",
"video/webm",
"video/x-msvideo",
];
/**
* First 4 hex chars of the sha256 used as a short display name.
* Note: 4 hex chars = 65,536 possible values. Collision is unlikely
@@ -137,18 +125,9 @@ export function useMediaUpload() {
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
const validFiles = files.filter((f) =>
ALLOWED_MEDIA_TYPES.includes(f.type),
);
if (validFiles.length === 0) {
setUploadState({
status: "error",
message:
"Unsupported file type. Supported: JPEG, PNG, GIF, WebP, MP4, MOV, MKV, WebM, AVI",
});
return;
}
// Accept any file. The Tauri layer and the relay enforce the deny-list
// (active-content + executables) and size caps; everything else uploads.
const validFiles = files;
setUploadingCount((c) => c + validFiles.length);
const baseIndex = reserveSlots(validFiles.length);
@@ -160,9 +139,10 @@ export function useMediaUpload() {
(async () => {
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([
...new Uint8Array(buffer),
]);
const descriptor = await uploadMediaBytes(
[...new Uint8Array(buffer)],
file.name,
);
fillSlot(slotIndex, descriptor);
} catch (err) {
onUploadError(err);
@@ -228,8 +208,10 @@ export function useMediaUpload() {
preventDefault: () => void;
}) => {
const items = Array.from(event.clipboardData.items);
// Only clipboard items that are actual files — `getAsFile()` returns null
// for text/string items, so pasting plain text never triggers an upload.
const mediaFiles = items
.filter((item) => ALLOWED_MEDIA_TYPES.includes(item.type))
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
if (mediaFiles.length === 0) return;
@@ -245,9 +227,10 @@ export function useMediaUpload() {
(async () => {
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([
...new Uint8Array(buffer),
]);
const descriptor = await uploadMediaBytes(
[...new Uint8Array(buffer)],
file.name,
);
fillSlot(slotIndex, descriptor);
} catch (err) {
onUploadError(err);
@@ -261,11 +244,13 @@ export function useMediaUpload() {
/** Upload a File directly — used by Tiptap's editorProps.handlePaste. */
const uploadFile = React.useCallback(
async (file: File) => {
if (!ALLOWED_MEDIA_TYPES.includes(file.type)) return;
setUploadingCount((c) => c + 1);
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
const descriptor = await uploadMediaBytes(
[...new Uint8Array(buffer)],
file.name,
);
onUploaded(descriptor);
} catch (err) {
onUploadError(err);
@@ -1,7 +1,7 @@
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { FileText, X } from "lucide-react";
import type { BlobDescriptor } from "@/shared/api/tauri";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -50,10 +50,51 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
{attachments.map((attachment) => {
const hash = shortHash(attachment.sha256);
const isVideo = attachment.type.startsWith("video/");
const isImage = attachment.type.startsWith("image/");
const isFile = !isVideo && !isImage;
const thumbUrl = attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: rewriteRelayUrl(attachment.url);
// Generic file: compact chip with a file icon + filename, plus the
// same remove button. No lightbox (nothing to preview).
if (isFile) {
const label =
attachment.filename ||
attachment.url.split("/").pop() ||
`file ${hash}`;
return (
<motion.div
key={attachment.url}
layout
initial={false}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="group relative"
>
<div className="flex h-5 max-w-[10rem] items-center gap-1 rounded border border-border/70 bg-muted px-1.5">
<FileText className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate text-[10px] text-muted-foreground">
{label}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
>
<X className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
</motion.div>
);
}
return (
<motion.div
key={attachment.url}
@@ -14,10 +14,7 @@ import {
stripImetaMediaLines,
} from "@/features/messages/lib/imetaMediaMarkdown";
import {
ALLOWED_MEDIA_TYPES,
useMediaUpload,
} from "@/features/messages/lib/useMediaUpload";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
@@ -576,11 +573,12 @@ export function MessageComposer({
editorProps: {
...richText.editor.options.editorProps,
handlePaste: (_view, event) => {
// --- Media paste ---
// --- File paste ---
// Any actual file (image, video, document, …) pastes as an
// attachment. String/text items have kind "string", so plain-text
// and code-block paste fall through to the handlers below.
const items = Array.from(event.clipboardData?.items ?? []);
const mediaItem = items.find((item) =>
ALLOWED_MEDIA_TYPES.includes(item.type),
);
const mediaItem = items.find((item) => item.kind === "file");
if (mediaItem) {
const file = mediaItem.getAsFile();
if (file) {
@@ -147,6 +147,7 @@ export function AvatarUpload({
<input
accept="image/gif,image/jpeg,image/png,image/webp"
className="hidden"
data-testid={`${testIdPrefix}-input`}
onChange={(event) => {
void handleFileChange(event);
}}
@@ -183,7 +184,10 @@ export function AvatarUpload({
</div>
{errorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<p
className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid={`${testIdPrefix}-error`}
>
{errorMessage}
</p>
) : null}
@@ -57,6 +57,14 @@ export function useAvatarUpload({
try {
const buffer = await file.arrayBuffer();
const uploaded = await uploadMediaBytes([...new Uint8Array(buffer)]);
// The shared upload path is now generic (accepts any non-denied file),
// so the browser-provided `file.type` check above is no longer a
// backstop. Verify the server-detected MIME is actually an image before
// accepting it as an avatar — defends against spoofed/blank picker MIME.
if (!uploaded.type.startsWith("image/")) {
setErrorMessage("Choose a PNG, JPG, GIF, or WebP image.");
return;
}
onUploadSuccess(uploaded.url);
} catch (error) {
setErrorMessage(
+4 -1
View File
@@ -759,6 +759,8 @@ export type BlobDescriptor = {
thumb?: string;
duration?: number;
image?: string;
/** Original filename for generic (non-media) file attachments. */
filename?: string;
};
export async function uploadMedia(
@@ -777,8 +779,9 @@ export async function pickAndUploadMedia(): Promise<BlobDescriptor[]> {
export async function uploadMediaBytes(
data: number[],
filename?: string,
): Promise<BlobDescriptor> {
return invokeTauri<BlobDescriptor>("upload_media_bytes", { data });
return invokeTauri<BlobDescriptor>("upload_media_bytes", { data, filename });
}
export async function editMessage(
+83 -2
View File
@@ -4,7 +4,7 @@ import ReactMarkdown, {
defaultUrlTransform,
} from "react-markdown";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Copy } from "lucide-react";
import { Copy, Download, FileText } from "lucide-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
@@ -36,9 +36,18 @@ import {
isImageOnlyParagraph,
shallowArrayEqual,
} from "./markdownUtils";
import { resolveFileCard } from "./markdownFileCard";
import { VideoPlayer } from "./VideoPlayer";
type ImetaLookup = Map<string, { image?: string; thumb?: string }>;
type ImetaEntry = {
image?: string;
thumb?: string;
m?: string;
size?: number;
filename?: string;
};
type ImetaLookup = Map<string, ImetaEntry>;
/**
* `urlTransform` for `<ReactMarkdown>` that preserves `sprout://message?…`
@@ -198,6 +207,60 @@ function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) {
);
}
/** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let size = bytes / 1024;
let i = 0;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i += 1;
}
return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[i]}`;
}
/**
* File card for a generic (non-image, non-video) attachment: icon, filename,
* size, and a download action. The blob is served with
* `Content-Disposition: attachment`, so following the link downloads it.
*/
function FileCard({
href,
filename,
size,
}: {
href: string;
filename: string;
size?: number;
}) {
const sizeLabel = size != null ? formatFileSize(size) : "";
return (
<a
href={href}
download={filename}
data-testid="file-card"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-xl border border-border/70 bg-muted/40 px-3 py-2 no-underline transition-colors hover:bg-muted/70"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-background text-muted-foreground">
<FileText className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{filename}
</span>
{sizeLabel ? (
<span className="block text-xs text-muted-foreground">
{sizeLabel}
</span>
) : null}
</span>
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
</a>
);
}
function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
@@ -226,6 +289,24 @@ function createMarkdownComponents(
return <span className="font-medium text-current">{children}</span>;
}
// Generic file attachment: a `[filename](url)` link whose href matches an
// imeta entry with a non-image, non-video MIME. Render a download card
// instead of a plain link. (Media uses the `img` renderer, not this path.)
const card = resolveFileCard(
href ? imetaByUrl?.get(href) : undefined,
href,
getReactNodeText(children),
);
if (card) {
return (
<FileCard
href={card.href}
filename={card.filename}
size={card.size}
/>
);
}
// Intercept `sprout://message?channel=…&id=…` links so a click navigates
// in-app instead of opening the URL in the OS browser. http(s) links
// continue to use the existing target="_blank" behavior.
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { resolveFileCard } from "./markdownFileCard.ts";
// A generic-file URL (non-media extension) does not match the relay-media
// proxy regex, so `rewriteRelayUrl` passes it through unchanged — assertions
// can compare hrefs directly.
const PDF_URL = "https://relay.example/media/" + "a".repeat(64) + ".pdf";
test("resolveFileCard: returns null when there is no imeta entry", () => {
assert.equal(resolveFileCard(undefined, PDF_URL, ""), null);
});
test("resolveFileCard: returns null without an href", () => {
assert.equal(
resolveFileCard({ m: "application/pdf" }, undefined, "doc"),
null,
);
});
test("resolveFileCard: returns null for image MIME (handled by img renderer)", () => {
assert.equal(
resolveFileCard({ m: "image/png" }, "https://b/x.png", ""),
null,
);
});
test("resolveFileCard: returns null for video MIME (handled by img renderer)", () => {
assert.equal(
resolveFileCard({ m: "video/mp4" }, "https://b/x.mp4", ""),
null,
);
});
test("resolveFileCard: returns null when imeta entry has no MIME", () => {
assert.equal(resolveFileCard({ size: 10 }, PDF_URL, ""), null);
});
test("resolveFileCard: builds a card for a generic file, preferring imeta filename", () => {
const card = resolveFileCard(
{ m: "application/pdf", size: 2048, filename: "Q3-budget.pdf" },
PDF_URL,
"link text",
);
assert.deepEqual(card, {
href: PDF_URL,
filename: "Q3-budget.pdf",
size: 2048,
});
});
test("resolveFileCard: falls back to link child text when imeta has no filename", () => {
const card = resolveFileCard(
{ m: "application/zip" },
PDF_URL,
" archive.zip ",
);
assert.equal(card?.filename, "archive.zip");
assert.equal(card?.size, undefined);
});
test("resolveFileCard: falls back to URL tail when no filename or child text", () => {
const card = resolveFileCard({ m: "application/octet-stream" }, PDF_URL, "");
assert.equal(card?.filename, "a".repeat(64) + ".pdf");
});
test("resolveFileCard: octet-stream (no magic bytes) is treated as a file", () => {
// Text/code/data upload with no magic signature — the Slack-like case.
const url = "https://relay.example/media/" + "b".repeat(64) + ".txt";
const card = resolveFileCard(
{ m: "application/octet-stream", filename: "notes.txt" },
url,
"",
);
assert.equal(card?.filename, "notes.txt");
});
+42
View File
@@ -0,0 +1,42 @@
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
/** Minimal shape of an imeta entry as consumed by the markdown renderer. */
export type FileCardImetaEntry = {
m?: string;
size?: number;
filename?: string;
};
export type ResolvedFileCard = {
href: string;
filename: string;
size?: number;
};
/**
* Decide whether a markdown link should render as a generic-file download
* card. A link qualifies when its href matches an imeta entry whose MIME is
* neither image nor video (media goes through the `img` renderer instead).
*
* Pure extracted from `markdown.tsx` so the FileCard decision (the riskiest
* part of the generic-file rendering path) is unit-testable without mounting
* React. Returns the resolved card props, or `null` to fall through to normal
* link handling.
*/
export function resolveFileCard(
entry: FileCardImetaEntry | undefined,
href: string | undefined,
childText: string,
): ResolvedFileCard | null {
if (
!href ||
!entry?.m ||
entry.m.startsWith("image/") ||
entry.m.startsWith("video/")
) {
return null;
}
const filename =
entry.filename || childText.trim() || href.split("/").pop() || "file";
return { href: rewriteRelayUrl(href), filename, size: entry.size };
}
+64 -13
View File
@@ -43,12 +43,26 @@ type E2eConfig = {
archivedIdentities?: string[];
oaOwnerIsMe?: boolean;
relayRole?: "owner" | "admin" | "member" | null;
// Descriptors returned by the mocked `pick_and_upload_media` /
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
// (e.g. a generic PDF) without a real upload pipeline. See
// tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors.
uploadDescriptors?: RawBlobDescriptor[];
};
relayHttpUrl?: string;
relayWsUrl?: string;
identity?: TestIdentity;
};
type RawBlobDescriptor = {
url: string;
sha256: string;
size: number;
type: string;
uploaded: number;
filename?: string;
};
type RawRelayMember = {
pubkey: string;
role: "owner" | "admin" | "member";
@@ -4268,6 +4282,32 @@ async function handleSearchMessages(
return { hits, found: hits.length };
}
/**
* Descriptors returned by the mocked upload commands. A spec can override via
* `MockBridgeOptions.uploadDescriptors`; otherwise we return a single generic
* PDF so the file-attachment flow (chip send FileCard) can be exercised
* out of the box.
*/
function resolveMockUploadDescriptors(
config: E2eConfig | undefined,
): RawBlobDescriptor[] {
const configured = config?.mock?.uploadDescriptors;
// `undefined` means "not configured" → default PDF. An explicit `[]` is a
// valid override (e.g. modelling a picker cancel / no-files-selected), so it
// must pass through rather than fall back to the default.
if (configured !== undefined) return configured;
return [
{
url: "https://mock.relay/media/" + "a".repeat(64) + ".pdf",
sha256: "a".repeat(64),
size: 12345,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: "quarterly-report.pdf",
},
];
}
async function handleSendChannelMessage(
args: {
channelId: string;
@@ -4275,25 +4315,29 @@ async function handleSendChannelMessage(
parentEventId?: string | null;
kind?: number | null;
mentionPubkeys?: string[];
mediaTags?: string[][] | null;
},
config: E2eConfig | undefined,
): Promise<RawSendChannelMessageResponse> {
const kind = args.kind ?? 9;
// NIP-92 imeta attachments. The real relay echoes these back on the stored
// event; mirror that here so attachment renderers (FileCard, images, video)
// have the imeta tags they key on. `null`/empty → no extra tags.
const mediaTags = args.mediaTags ?? [];
const identity = getIdentity(config);
if (!identity) {
const createdAt = Math.floor(Date.now() / 1000);
const mockPubkey = getMockMemberPubkey(config);
if (!args.parentEventId) {
const event = createMockEvent(
kind,
args.content,
buildTopLevelMessageTags(
const event = createMockEvent(kind, args.content, [
...buildTopLevelMessageTags(
args.channelId,
args.mentionPubkeys,
mockPubkey,
),
);
...mediaTags,
]);
recordMockMessage(args.channelId, event);
emitMockLiveEvent(args.channelId, event);
@@ -4343,13 +4387,16 @@ async function handleSendChannelMessage(
pubkey: mockPubkey,
created_at: createdAt,
kind,
tags: buildReplyMessageTags(
args.channelId,
mockPubkey,
args.parentEventId,
rootEventId,
args.mentionPubkeys,
),
tags: [
...buildReplyMessageTags(
args.channelId,
mockPubkey,
args.parentEventId,
rootEventId,
args.mentionPubkeys,
),
...mediaTags,
],
content: args.content.trim(),
sig: "mocksig".repeat(20).slice(0, 128),
};
@@ -4384,7 +4431,7 @@ async function handleSendChannelMessage(
const result = await submitSignedEvent(config, {
kind,
content: args.content.trim(),
tags,
tags: [...tags, ...mediaTags],
});
return {
@@ -5030,6 +5077,10 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleSendChannelMessage>[0],
activeConfig,
);
case "pick_and_upload_media":
return resolveMockUploadDescriptors(activeConfig);
case "upload_media_bytes":
return resolveMockUploadDescriptors(activeConfig)[0];
case "get_event":
return handleGetEvent(
payload as Parameters<typeof handleGetEvent>[0],
+12
View File
@@ -11,5 +11,17 @@ export function resolve(specifier, context, nextResolve) {
const resolved = `${srcRoot}/${specifier.slice(2)}.ts`;
return nextResolve(resolved, context);
}
// Resolve extensionless relative TS imports (e.g. `./parseImeta`) — the app's
// bundler adds the extension, but node's ESM resolver does not. Without this,
// any .ts that relative-imports a sibling .ts can't be imported from a test,
// which previously forced stale inlined copies of the source under test.
if (
(specifier.startsWith("./") || specifier.startsWith("../")) &&
!path.extname(specifier) &&
context.parentURL
) {
const resolved = new URL(`${specifier}.ts`, context.parentURL).href;
return nextResolve(resolved, context);
}
return nextResolve(specifier, context);
}
+88
View File
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Exercises the generic file-attachment UI contract end-to-end through the
// mock Tauri bridge: paperclip upload → composer chip → send → FileCard in the
// timeline. This guards the frontend wiring (the riskiest, previously
// untested path). It does NOT prove the real relay store/serve round-trip —
// that lives in the Rust media + relay tests.
test.beforeEach(async ({ page }) => {
await installMockBridge(page, {
uploadDescriptors: [
{
url: `https://mock.relay/media/${"a".repeat(64)}.pdf`,
sha256: "a".repeat(64),
size: 12345,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: "quarterly-report.pdf",
},
],
});
});
test("upload a file and see a FileCard in the timeline", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Paperclip → mocked pick_and_upload_media returns the PDF descriptor.
await page.getByRole("button", { name: "Attach image" }).click();
// The composer shows a chip with the original filename.
await expect(page.getByTestId("message-composer")).toContainText(
"quarterly-report.pdf",
);
// Send the (attachment-only) message.
await page.getByTestId("send-message").click();
// A FileCard renders in the timeline: a download link carrying the filename
// and pointing at the blob URL.
const card = page.getByTestId("file-card");
await expect(card).toBeVisible();
await expect(card).toContainText("quarterly-report.pdf");
await expect(card).toHaveAttribute(
"href",
`https://mock.relay/media/${"a".repeat(64)}.pdf`,
);
await expect(card).toHaveAttribute("download", "quarterly-report.pdf");
});
test("forum posts emit a FileCard for generic attachments, not a broken image", async ({
page,
}) => {
// Regression guard for the ForumComposer bug: it used to hand-build content
// as `![image](url)` for every non-video attachment (and omit the `filename`
// imeta tag), so a PDF posted in a forum rendered as a broken inline image
// and lost its label. The fix routes forum/notes posts through the same
// `buildOutgoingMessage` builder as chat. This test would fail (no FileCard)
// if ForumComposer ever drifts back to hand-building media markdown.
await page.goto("/");
// "watercooler" is a seeded forum the mock identity is a member of.
await page.getByTestId("channel-watercooler").click();
// Open the new-post composer ("Start a new post...").
await page.getByRole("button", { name: "Start a new post..." }).click();
// Paperclip → mocked pick_and_upload_media returns the PDF descriptor.
await page.getByRole("button", { name: "Attach image" }).click();
// Submit the (attachment-only) forum post.
await page.getByTestId("send-message").click();
// The post renders through the shared Markdown component as a FileCard —
// a download link carrying the filename and pointing at the blob URL — NOT
// an inline image.
const card = page.getByTestId("file-card");
await expect(card).toBeVisible();
await expect(card).toContainText("quarterly-report.pdf");
await expect(card).toHaveAttribute(
"href",
`https://mock.relay/media/${"a".repeat(64)}.pdf`,
);
await expect(card).toHaveAttribute("download", "quarterly-report.pdf");
});
+71
View File
@@ -144,6 +144,77 @@ test("page 1 accepts an avatar URL as the secondary avatar path", async ({
await expect(page.getByTestId("onboarding-provider-goose")).toBeVisible();
});
test("avatar upload rejects a file whose server-detected MIME is not an image", async ({
page,
}) => {
// Models a spoofed/blank picker MIME: the picked file claims to be an image
// (passes the browser-side accept filter) but the shared generic upload path
// returns a non-image descriptor. The post-upload backstop must reject it so
// a non-image can't become an avatar (regression guard — the shared upload
// path no longer rejects non-images server-side).
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
uploadDescriptors: [
{
url: `https://mock.relay/media/${"b".repeat(64)}.pdf`,
sha256: "b".repeat(64),
size: 4096,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: "not-an-image.pdf",
},
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "looks-like.png",
mimeType: "image/png",
buffer: Buffer.from("not really a png"),
});
await expect(page.getByTestId("onboarding-avatar-error")).toContainText(
"Choose a PNG, JPG, GIF, or WebP image.",
);
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue("");
});
test("avatar upload accepts a file whose server-detected MIME is an image", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
const url = `https://mock.relay/media/${"c".repeat(64)}.png`;
await installMockBridge(
page,
{
uploadDescriptors: [
{
url,
sha256: "c".repeat(64),
size: 2048,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
},
],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "avatar.png",
mimeType: "image/png",
buffer: Buffer.from("png bytes"),
});
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue(url);
await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
});
test("first-run onboarding keeps the shell hidden through both pages and only marks Home seen after finish", async ({
page,
}) => {
+14
View File
@@ -70,6 +70,20 @@ type MockBridgeOptions = {
* evaluates false).
*/
relayRole?: "owner" | "admin" | "member" | null;
/**
* Descriptors returned by the mocked `pick_and_upload_media` /
* `upload_media_bytes` commands. When omitted, the bridge returns a single
* generic PDF so the file-attachment flow can be exercised by default. An
* explicit `[]` is honoured (models a picker cancel / no files selected).
*/
uploadDescriptors?: {
url: string;
sha256: string;
size: number;
type: string;
uploaded: number;
filename?: string;
}[];
};
type BridgeOptions = {