mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 1–8 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();
|
||||
|
||||
// 1–3 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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 1–255 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: 1–8 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod identity;
|
||||
mod identity_archive;
|
||||
mod media;
|
||||
mod media_download;
|
||||
mod media_transcode;
|
||||
mod messages;
|
||||
pub mod pairing;
|
||||
mod personas;
|
||||
|
||||
@@ -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` : `\n`;
|
||||
}
|
||||
// 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` : `\n`;
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -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 `` 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 `` 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` : `\n`;
|
||||
export function formatImetaMediaLine({
|
||||
url,
|
||||
type,
|
||||
filename,
|
||||
}: ImetaMedia): string {
|
||||
if (type.startsWith("video/")) return `\n`;
|
||||
if (type.startsWith("image/")) return `\n`;
|
||||
// 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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 `` 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");
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user