//! WOPI Discovery service. //! //! Fetches and caches the WOPI discovery XML from the editor (Collabora/OnlyOffice). //! The discovery document describes which file types the editor supports and //! provides the action URLs for view/edit operations. use quick_xml::Reader; use quick_xml::events::Event; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use crate::common::errors::{DomainError, ErrorKind}; /// A single WOPI action from the discovery XML. #[derive(Clone, Debug)] pub struct WopiAction { /// Action name: "view", "edit", "editnew", etc. pub name: String, /// File extension: "docx", "xlsx", etc. pub ext: String, /// Template URL with placeholders (WOPI_SOURCE, UI_LLCC) pub urlsrc: String, } /// Caches parsed WOPI discovery data from the editor. pub struct WopiDiscoveryService { discovery_url: String, /// Map: extension -> Vec actions: Arc>>>, last_fetched: Arc>>, cache_ttl: Duration, /// HTTP client with timeout (shared across requests). http_client: reqwest::Client, /// Mutex to prevent concurrent refresh stampede. refreshing: Arc>, } impl WopiDiscoveryService { pub fn new(discovery_url: String, cache_ttl_secs: u64) -> Self { let http_client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .expect("Failed to build HTTP client for WOPI discovery"); Self { discovery_url, actions: Arc::new(RwLock::new(HashMap::new())), last_fetched: Arc::new(RwLock::new(None)), cache_ttl: Duration::from_secs(cache_ttl_secs), http_client, refreshing: Arc::new(tokio::sync::Mutex::new(())), } } /// Fetch and parse the discovery XML from the WOPI client. pub async fn refresh_discovery(&self) -> Result<(), DomainError> { tracing::info!("Fetching WOPI discovery from {}", self.discovery_url); let response = self .http_client .get(&self.discovery_url) .send() .await .map_err(|e| { DomainError::new( ErrorKind::InternalError, "WopiDiscovery", format!("Failed to fetch discovery XML: {}", e), ) })?; let response = response.error_for_status().map_err(|e| { DomainError::new( ErrorKind::InternalError, "WopiDiscovery", format!("Discovery endpoint returned error: {}", e), ) })?; let xml_text = response.text().await.map_err(|e| { DomainError::new( ErrorKind::InternalError, "WopiDiscovery", format!("Failed to read discovery response: {}", e), ) })?; let actions = Self::parse_discovery_xml(&xml_text)?; tracing::info!( "WOPI discovery loaded: {} extensions supported", actions.len() ); *self.actions.write().await = actions; *self.last_fetched.write().await = Some(Instant::now()); Ok(()) } /// Ensure the discovery cache is fresh, refreshing if needed. /// Uses a mutex so only one caller refreshes at a time (stampede prevention). async fn ensure_fresh(&self) -> Result<(), DomainError> { let needs_refresh = { let last = self.last_fetched.read().await; match *last { None => true, Some(t) => t.elapsed() > self.cache_ttl, } }; if needs_refresh { let _guard = self.refreshing.lock().await; // Re-check after acquiring the lock (another caller may have refreshed) let still_stale = { let last = self.last_fetched.read().await; match *last { None => true, Some(t) => t.elapsed() > self.cache_ttl, } }; if still_stale { self.refresh_discovery().await?; } } Ok(()) } /// Get the editor action URL for a given file extension and action. /// /// Replaces `WOPI_SOURCE` placeholder with the provided `wopi_src` URL. pub async fn get_action_url( &self, extension: &str, action: &str, wopi_src: &str, ) -> Result, DomainError> { self.ensure_fresh().await?; let actions = self.actions.read().await; let ext_lower = extension.to_lowercase(); if let Some(ext_actions) = actions.get(&ext_lower) && let Some(wopi_action) = ext_actions.iter().find(|a| a.name == action) { let mut url = wopi_action .urlsrc .replace("WOPI_SOURCE", &urlencoding::encode(wopi_src)) .replace("UI_LLCC", "en-US"); // Clean up unused placeholder parameters url = Self::clean_placeholder_params(&url); // Some discovery documents return a bare `cool.html?` URL without // embedding WOPISrc in the template. Ensure WOPISrc is always present. if !Self::has_query_param(&url, "WOPISrc") { url = Self::append_query_param(&url, "WOPISrc", &urlencoding::encode(wopi_src)); } return Ok(Some(url)); } Ok(None) } /// Check if an extension is supported for a given action. pub async fn supports_action( &self, extension: &str, action: &str, ) -> Result { self.ensure_fresh().await?; let actions = self.actions.read().await; let ext_lower = extension.to_lowercase(); Ok(actions .get(&ext_lower) .is_some_and(|acts| acts.iter().any(|a| a.name == action))) } /// Get list of all supported extensions. pub async fn get_supported_extensions(&self) -> Result, DomainError> { self.ensure_fresh().await?; let actions = self.actions.read().await; Ok(actions.keys().cloned().collect()) } /// Parse the WOPI discovery XML into a map of extension -> actions. fn parse_discovery_xml(xml: &str) -> Result>, DomainError> { let mut reader = Reader::from_str(xml); let mut actions: HashMap> = HashMap::new(); let mut buf = Vec::new(); loop { match reader.read_event_into(&mut buf) { Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) if e.name().as_ref() == b"action" => { let mut name = String::new(); let mut ext = String::new(); let mut urlsrc = String::new(); for attr in e.attributes().flatten() { let value = attr .decoded_and_normalized_value( quick_xml::XmlVersion::Implicit1_0, reader.decoder(), ) .map(|value| value.into_owned()) .unwrap_or_else(|_| String::from_utf8_lossy(&attr.value).to_string()); match attr.key.as_ref() { b"name" => name = value, b"ext" => ext = value, b"urlsrc" => urlsrc = value, _ => {} } } if !ext.is_empty() && !urlsrc.is_empty() { actions .entry(ext.to_lowercase()) .or_default() .push(WopiAction { name, ext: ext.to_lowercase(), urlsrc, }); } } Ok(Event::Eof) => break, Err(e) => { return Err(DomainError::new( ErrorKind::InternalError, "WopiDiscovery", format!("Failed to parse discovery XML: {}", e), )); } _ => {} } buf.clear(); } Ok(actions) } /// Remove unused placeholder parameters from the URL. fn clean_placeholder_params(url: &str) -> String { let mut result = url.to_string(); while let Some(start) = result.find('<') { if let Some(end) = result[start..].find('>') { result = format!("{}{}", &result[..start], &result[start + end + 1..]); } else { break; } } result = result .trim_end_matches('&') .trim_end_matches('?') .to_string(); result } fn has_query_param(url: &str, key: &str) -> bool { if let Some((_, query)) = url.split_once('?') { for part in query.split('&') { let name = part.split('=').next().unwrap_or(""); if name == key { return true; } } } false } fn append_query_param(url: &str, key: &str, value: &str) -> String { let separator = if url.contains('?') { if url.ends_with('?') || url.ends_with('&') { "" } else { "&" } } else { "?" }; format!("{}{}{}={}", url, separator, key, value) } } // Minimal inline URL encoding implementation (no external crate dependency). // Matches the pattern used by oidc_service.rs in this codebase. mod urlencoding { pub fn encode(input: &str) -> String { let mut result = String::with_capacity(input.len() * 3); for byte in input.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { result.push(byte as char); } _ => { result.push('%'); result.push_str(&format!("{:02X}", byte)); } } } result } } #[cfg(test)] mod tests { use super::*; const SAMPLE_DISCOVERY: &str = r#" "#; #[test] fn test_parse_discovery_xml() { let actions = WopiDiscoveryService::parse_discovery_xml(SAMPLE_DISCOVERY).expect("Should parse"); assert!(actions.contains_key("docx")); assert!(actions.contains_key("xlsx")); assert!(actions.contains_key("pptx")); let docx_actions = &actions["docx"]; assert_eq!(docx_actions.len(), 2); assert!(docx_actions.iter().any(|a| a.name == "view")); assert!(docx_actions.iter().any(|a| a.name == "edit")); assert!( docx_actions .iter() .all(|action| !action.urlsrc.contains("&")) ); } #[tokio::test] async fn test_get_action_url_decodes_xml_escaped_ampersands() { let service = WopiDiscoveryService::new("http://example.test/discovery".to_string(), 60); *service.actions.write().await = WopiDiscoveryService::parse_discovery_xml(SAMPLE_DISCOVERY).expect("Should parse"); *service.last_fetched.write().await = Some(Instant::now()); let url = service .get_action_url("docx", "edit", "https://oxicloud.test/wopi/files/abc") .await .expect("Should build URL") .expect("Action URL should exist"); assert!(url.contains("lang=en-US")); assert!(!url.contains("&")); } #[test] fn test_clean_placeholder_params() { let url = "https://example.com/edit?WOPISrc=http%3A%2F%2Flocalhost&"; let cleaned = WopiDiscoveryService::clean_placeholder_params(url); assert!(!cleaned.contains('<')); assert!(!cleaned.contains('>')); assert!(cleaned.contains("WOPISrc=")); } #[test] fn test_append_wopisrc_when_missing() { let base = "http://127.0.0.1:9980/browser/hash/cool.html?"; assert!(!WopiDiscoveryService::has_query_param(base, "WOPISrc")); let appended = WopiDiscoveryService::append_query_param( base, "WOPISrc", "http%3A%2F%2F127.0.0.1%3A8086%2Fwopi%2Ffiles%2Fabc", ); assert!(WopiDiscoveryService::has_query_param(&appended, "WOPISrc")); assert!(appended.contains("WOPISrc=http%3A%2F%2F127.0.0.1%3A8086%2Fwopi%2Ffiles%2Fabc")); } }