diff --git a/Cargo.lock b/Cargo.lock index 57ca7823..0090ca81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -366,6 +366,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1416,6 +1427,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "inout" version = "0.1.4" @@ -1838,6 +1858,7 @@ dependencies = [ "http-range-header", "hyper", "image", + "infer", "jsonwebtoken", "lru", "md5", diff --git a/Cargo.toml b/Cargo.toml index 1386d4fb..480c91ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus base64 = "0.22.1" fs2 = "0.4" rayon = "1.10" +infer = "0.19" async-compression = { version = "0.4", features = ["tokio", "gzip"] } async_zip = { version = "0.0.18", features = ["tokio", "deflate"] } diff --git a/src/common/mime_detect.rs b/src/common/mime_detect.rs new file mode 100644 index 00000000..25614a77 --- /dev/null +++ b/src/common/mime_detect.rs @@ -0,0 +1,85 @@ +//! MIME type detection using magic bytes (infer) + extension fallback (mime_guess). +//! +//! Priority order: +//! 1. If the claimed Content-Type is specific (not `application/octet-stream`), trust it. +//! 2. Read first bytes of the file and detect via magic bytes (`infer` crate). +//! 3. Fall back to extension-based detection (`mime_guess`). +//! 4. If nothing matches, return the original claimed type. +//! +//! Performance: < 1µs for the `infer` check (reads only header bytes, no allocation). + +use std::path::Path; + +/// Maximum bytes to read for magic-byte detection. +const MAGIC_BYTES_LEN: usize = 8192; + +/// Refine a claimed MIME type using magic bytes and filename extension. +/// +/// This is a synchronous function — the caller should already have the first +/// bytes of the file available (or call the async wrapper below). +/// +/// # Arguments +/// * `buf` — first bytes of the file (at least 8192 for best results) +/// * `filename` — original filename (used for extension fallback) +/// * `claimed` — the Content-Type sent by the client +pub fn refine_content_type(buf: &[u8], filename: &str, claimed: &str) -> String { + // If the client sent a specific type (not generic), trust it + if !claimed.is_empty() + && claimed != "application/octet-stream" + && claimed != "binary/octet-stream" + { + return claimed.to_string(); + } + + // 1. Try magic bytes detection + if let Some(kind) = infer::get(buf) { + return kind.mime_type().to_string(); + } + + // 2. Try extension-based detection + let guess = mime_guess::from_path(filename); + if let Some(mime) = guess.first() { + return mime.to_string(); + } + + // 3. Fall back to claimed type + claimed.to_string() +} + +/// Async helper: reads the first bytes of a file on disk and refines the MIME type. +/// +/// Designed for the upload path where the file has been spooled to a temp path. +pub async fn refine_content_type_from_file( + temp_path: &Path, + filename: &str, + claimed: &str, +) -> String { + // Fast path: if the client gave us a specific type, trust it + if !claimed.is_empty() + && claimed != "application/octet-stream" + && claimed != "binary/octet-stream" + { + return claimed.to_string(); + } + + // Read first bytes for magic detection + match tokio::fs::read(temp_path).await { + Ok(full) => { + let len = full.len().min(MAGIC_BYTES_LEN); + refine_content_type(&full[..len], filename, claimed) + } + Err(e) => { + tracing::warn!( + "MIME detection: failed to read {} for magic bytes: {}", + temp_path.display(), + e + ); + // Fall back to extension + let guess = mime_guess::from_path(filename); + if let Some(mime) = guess.first() { + return mime.to_string(); + } + claimed.to_string() + } + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index b0892873..2abb6785 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,4 +1,5 @@ pub mod config; pub mod di; pub mod errors; +pub mod mime_detect; pub mod stubs; diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index e9eb2032..3b7e7924 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -299,6 +299,14 @@ impl ChunkedUploadHandler { } }; + // ── MIME detection (magic bytes + extension fallback) ───── + let content_type = crate::common::mime_detect::refine_content_type_from_file( + &assembled_path, + &filename, + &content_type, + ) + .await; + // Upload from assembled file on disk — zero extra RAM copies, hash pre-computed match upload_service .upload_file_from_path( diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index ad01dccb..26e05d21 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -179,6 +179,14 @@ impl FileHandler { // Finalize hash let hash = hex::encode(hasher.finalize()); + // ── MIME detection (magic bytes + extension fallback) ─ + let content_type = crate::common::mime_detect::refine_content_type_from_file( + &temp_path, + &filename, + &content_type, + ) + .await; + // ── Quota enforcement ──────────────────────────────── if let Some(storage_svc) = state.storage_usage_service.as_ref() && let Err(err) = storage_svc diff --git a/static/css/views/inlineViewer.css b/static/css/views/inlineViewer.css index 2dcb0bc9..b22d1915 100644 --- a/static/css/views/inlineViewer.css +++ b/static/css/views/inlineViewer.css @@ -212,6 +212,54 @@ tab-size: 4; } +/* Video viewer */ +.inline-viewer-video { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 4px; + background-color: #000; +} + +/* Audio viewer */ +.inline-viewer-audio-wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24px; + padding: 48px 32px; + max-width: 500px; + width: 100%; +} + +.inline-viewer-audio-icon { + font-size: 80px; + color: #94a3b8; + animation: audio-pulse 2s ease-in-out infinite; +} + +@keyframes audio-pulse { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.05); } +} + +.inline-viewer-audio-name { + font-size: 16px; + font-weight: 500; + color: #475569; + text-align: center; + word-break: break-word; + max-width: 100%; +} + +.inline-viewer-audio { + width: 100%; + max-width: 460px; + border-radius: 8px; + outline: none; +} + /* Responsive adjustments */ @media (max-width: 768px) { .inline-viewer-content { diff --git a/static/js/app/uiFileTypes.js b/static/js/app/uiFileTypes.js index 153b1c23..51be84fc 100644 --- a/static/js/app/uiFileTypes.js +++ b/static/js/app/uiFileTypes.js @@ -8,6 +8,8 @@ const uiFileTypes = { if (!file || !file.mime_type) return false; if (file.mime_type.startsWith('image/')) return true; if (file.mime_type === 'application/pdf') return true; + if (file.mime_type.startsWith('audio/')) return true; + if (file.mime_type.startsWith('video/')) return true; return window.isTextViewable ? window.isTextViewable(file.mime_type) : false; }, diff --git a/static/js/features/files/inlineViewer.js b/static/js/features/files/inlineViewer.js index b732ea79..8bd37ba2 100644 --- a/static/js/features/files/inlineViewer.js +++ b/static/js/features/files/inlineViewer.js @@ -153,6 +153,32 @@ class InlineViewer { // Create text viewer using authenticated fetch this.createTextViewer(file, container, loader); } + else if (file.mime_type && file.mime_type.startsWith('audio/')) { + // Hide zoom controls for audio + controls.style.display = 'none'; + + // Show loading indicator + const loader = document.createElement('div'); + loader.className = 'inline-viewer-loader'; + loader.innerHTML = ''; + container.appendChild(loader); + + // Create audio player + this.createMediaViewer(file, 'audio', container, loader); + } + else if (file.mime_type && file.mime_type.startsWith('video/')) { + // Hide zoom controls for video + controls.style.display = 'none'; + + // Show loading indicator + const loader = document.createElement('div'); + loader.className = 'inline-viewer-loader'; + loader.innerHTML = ''; + container.appendChild(loader); + + // Create video player + this.createMediaViewer(file, 'video', container, loader); + } else { // Hide zoom controls for unsupported files controls.style.display = 'none'; @@ -326,6 +352,109 @@ class InlineViewer { } } + // Creates an audio or video player using blob URL (authenticated fetch) + async createMediaViewer(file, mediaType, container, loader) { + try { + console.log(`Creating ${mediaType} player for:`, file.name); + + // Fetch file with auth header (same pattern as images/PDFs) + const token = localStorage.getItem('oxicloud_token'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + const response = await fetch(`/api/files/${file.id}?inline=true`, { headers }); + + if (!response.ok) { + throw new Error(`Error fetching file: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + // Remove loader + if (loader && loader.parentNode) { + loader.parentNode.removeChild(loader); + } + + if (mediaType === 'audio') { + // Wrapper with icon + player + const wrapper = document.createElement('div'); + wrapper.className = 'inline-viewer-audio-wrapper'; + + const icon = document.createElement('div'); + icon.className = 'inline-viewer-audio-icon'; + icon.innerHTML = ''; + wrapper.appendChild(icon); + + const nameEl = document.createElement('div'); + nameEl.className = 'inline-viewer-audio-name'; + nameEl.textContent = file.name; + wrapper.appendChild(nameEl); + + const audio = document.createElement('audio'); + audio.className = 'inline-viewer-audio'; + audio.controls = true; + audio.preload = 'metadata'; + audio.src = blobUrl; + wrapper.appendChild(audio); + + // Fallback message for unsupported codecs + audio.addEventListener('error', () => { + console.warn('Audio playback error — codec may not be supported'); + wrapper.innerHTML = ''; + const msg = document.createElement('div'); + msg.className = 'inline-viewer-message'; + msg.innerHTML = ` +
+Your browser cannot play this audio format.
+Click "Download" to save the file.
+Your browser cannot play this video format.
+Click "Download" to save the file.
+