Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-encrypted-media-content-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix encrypted images and GIFs failing to render by sniffing the decrypted content type in the native media handler and skipping thumbnail requests for animated image formats.
11 changes: 9 additions & 2 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ ts-rs = "12.0.0"
sha2 = "0.10"
aes = "0.8"
ctr = "0.9"
infer = { version = "0.22", default-features = false }
percent-encoding = "2"
reqwest = { version = "0.12", default-features = false, features = ["stream"] }
async-stream = "0.3"
Expand Down
128 changes: 121 additions & 7 deletions src-tauri/src/network/media_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,13 +345,22 @@ async fn ensure_cached_with_limits(
if let Some(content_type) =
read_content_type(body_path.clone(), content_type_path.clone()).await
{
let content_type =
sniff_and_fix_content_type(body_path.clone(), content_type_path.clone(), content_type)
.await;
return Ok((content_type, None, body_path));
}

// Fast path 2: Temporary cache hit (sequential range requests for oversized media)
if let Some(content_type) =
read_content_type(temp_body_path.clone(), temp_content_type_path.clone()).await
{
let content_type = sniff_and_fix_content_type(
temp_body_path.clone(),
temp_content_type_path.clone(),
content_type,
)
.await;
return Ok((content_type, None, temp_body_path));
}

Expand All @@ -362,13 +371,22 @@ async fn ensure_cached_with_limits(
if let Some(content_type) =
read_content_type(body_path.clone(), content_type_path.clone()).await
{
let content_type =
sniff_and_fix_content_type(body_path.clone(), content_type_path.clone(), content_type)
.await;
return Ok((content_type, None, body_path));
}

// Double-check temporary cache inside gate lock
if let Some(content_type) =
read_content_type(temp_body_path.clone(), temp_content_type_path.clone()).await
{
let content_type = sniff_and_fix_content_type(
temp_body_path.clone(),
temp_content_type_path.clone(),
content_type,
)
.await;
return Ok((content_type, None, temp_body_path));
}

Expand Down Expand Up @@ -423,7 +441,7 @@ async fn fetch_and_cache(
max_persistent_cache_bytes: u64,
max_temp_cache_bytes: u64,
) -> Result<(String, Option<Arc<Vec<u8>>>, PathBuf), StatusCode> {
let _permit = state
let permit = state
.semaphore
.acquire()
.await
Expand Down Expand Up @@ -462,6 +480,7 @@ async fn fetch_and_cache(
Ok((decrypted_body, decrypted_ct)) => (decrypted_body, decrypted_ct),
Err(status) => return Err(status),
};
drop(permit);

if body.len() as u64 > max_temp_cache_bytes {
return Ok((content_type, Some(Arc::new(body)), temp_body_path));
Expand Down Expand Up @@ -499,6 +518,22 @@ async fn fetch_and_cache(
}
}

/// Sniff the image MIME type from magic bytes of decrypted content.
/// Restricted to an image allowlist and never returns SVG (which can carry scripts).
/// Used as a fallback when the registered content type is missing or octet-stream.
fn sniff_image_content_type(bytes: &[u8]) -> Option<&'static str> {
const ALLOWED: [&str; 5] = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
];
infer::get(bytes)
.filter(|kind| ALLOWED.contains(&kind.mime_type()))
.map(|kind| kind.mime_type())
}

/// Decrypt the body if encryption params exist for this URL.
fn decrypt_if_encrypted(
state: &MediaSessionState,
Expand All @@ -524,7 +559,6 @@ fn decrypt_if_encrypted(
hasher.update(&ciphertext);
let actual_sha256 = hasher.finalize().to_vec();
if actual_sha256 != params.expected_sha256 {
let _ = state.encryption.write().map(|mut guard| guard.remove(url));
return Err(StatusCode::BAD_GATEWAY);
}

Expand All @@ -545,7 +579,15 @@ fn decrypt_if_encrypted(
// Remove encryption params — decrypted content is now cached on disk
let _ = state.encryption.write().map(|mut guard| guard.remove(url));

Ok((plaintext, params.content_type))
let final_content_type =
if params.content_type.is_empty() || params.content_type == "application/octet-stream" {
sniff_image_content_type(&plaintext)
.map(|s| s.to_owned())
.unwrap_or(params.content_type)
} else {
params.content_type
};
Ok((plaintext, final_content_type))
}

async fn write_cache(
Expand Down Expand Up @@ -587,6 +629,34 @@ async fn read_content_type(body_path: PathBuf, content_type_path: PathBuf) -> Op
.unwrap_or(None)
}

/// On a cache hit where the stored content type is octet-stream, re-sniff the
/// body file's magic bytes and rewrite the .ct file if a real image type is found.
async fn sniff_and_fix_content_type(
body_path: PathBuf,
content_type_path: PathBuf,
stored_ct: String,
) -> String {
if stored_ct != "application/octet-stream" {
return stored_ct;
}
let ct_for_closure = stored_ct.clone();
tokio::task::spawn_blocking(move || {
let mut file = match fs::File::open(&body_path) {
Ok(f) => f,
Err(_) => return ct_for_closure,
};
let mut buf = [0u8; 64];
let n = file.read(&mut buf).unwrap_or(0);
if let Some(sniffed) = sniff_image_content_type(&buf[..n]) {
let _ = fs::write(&content_type_path, sniffed);
return sniffed.to_owned();
}
ct_for_closure
})
.await
.unwrap_or(stored_ct)
}

async fn read_full(body_path: PathBuf) -> Result<Vec<u8>, StatusCode> {
tokio::task::spawn_blocking(move || fs::read(&body_path))
.await
Expand Down Expand Up @@ -706,15 +776,17 @@ fn evict_directory_if_needed(dir: &Path, max_bytes: u64) {
// Shared 200/206 headers. Media is content-addressed and the URL is session-scoped, so
// it is safe to let the webview cache it as immutable and to advertise Range support.
fn media_response_builder(status: StatusCode, content_type: &str) -> ResponseBuilder {
let cache_control = if content_type == "application/octet-stream" {
"no-store"
} else {
"private, max-age=31536000, immutable"
};
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header(header::ACCEPT_RANGES, "bytes")
.header(
header::CACHE_CONTROL,
"private, max-age=31536000, immutable",
)
.header(header::CACHE_CONTROL, cache_control)
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(
header::CONTENT_SECURITY_POLICY,
Expand Down Expand Up @@ -956,4 +1028,46 @@ mod tests {
let result = super::normalize_encryption_key(input);
assert_eq!(result, input);
}

#[test]
fn sniff_detects_png() {
let png_header = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(
super::sniff_image_content_type(&png_header),
Some("image/png")
);
}

#[test]
fn sniff_rejects_unknown() {
assert_eq!(super::sniff_image_content_type(&[0x00, 0x01, 0x02]), None);
}

#[test]
fn sniff_does_not_detect_svg() {
let svg = b"<svg xmlns='http://www.w3.org/2000/svg'>";
assert_eq!(super::sniff_image_content_type(svg), None);
}

#[test]
fn octet_stream_response_is_not_cached_immutable() {
let response = super::media_response_builder(StatusCode::OK, "application/octet-stream")
.body(Vec::<u8>::new())
.unwrap();
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
}

#[test]
fn image_response_is_cached_immutable() {
let response = super::media_response_builder(StatusCode::OK, "image/png")
.body(Vec::<u8>::new())
.unwrap();
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"private, max-age=31536000, immutable"
);
}
}
11 changes: 8 additions & 3 deletions src/app/components/message/content/ImageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const ImageContent = as<'div', ImageContentProps>(

const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [useFullDownload, setUseFullDownload] = useState(false);
const [viewer, setViewer] = useState(false);
const [viewerFullSrc, setViewerFullSrc] = useState<string | null>(null);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
Expand All @@ -154,11 +155,11 @@ export const ImageContent = as<'div', ImageContentProps>(

const rawMediaUrl = useMemo(() => {
if (url.startsWith('http')) return url;
if (encInfo) {
if (encInfo || isGif || useFullDownload) {
return mxcUrlToHttp(mx, url, useAuthentication) ?? undefined;
}
return mxcUrlToHttp(mx, url, useAuthentication, 800, 600, 'scale') ?? undefined;
}, [mx, url, useAuthentication, encInfo]);
}, [mx, url, useAuthentication, encInfo, isGif, useFullDownload]);

const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl);

Expand Down Expand Up @@ -213,9 +214,13 @@ export const ImageContent = as<'div', ImageContentProps>(

const handleRetry = () => {
setError(false);
loadSrc();
setUseFullDownload(true);
};

useEffect(() => {
if (useFullDownload) loadSrc();
}, [useFullDownload, loadSrc]);

useEffect(() => {
if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]);
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/url-preview/UrlPreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ export const UrlPreviewCard = as<
const cached = clientCache.get(url);
if (cached !== undefined) return cached;
const previewResult = mx?.getUrlPreview(url, ts);
if (!previewResult) return Promise.resolve(null);
clientCache.set(url, previewResult);
previewResult.finally(() => clientCache.delete(url));
previewResult.finally(() => clientCache.delete(url)).catch(() => {});
return previewResult;
}
return Promise.resolve(bundle);
Expand Down
9 changes: 8 additions & 1 deletion src/app/utils/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,12 @@ export const fetch: AppFetch = async (input, init) => {
}

const tauriFetch = await getTauriFetch();
return tauriFetch(request, init);
try {
return await tauriFetch(request, init);
} catch (e) {
if (e instanceof SyntaxError) {
return new Response(null, { status: 502, statusText: 'Bad Gateway' });
}
throw e;
}
};
Loading