feat(wopi): add WOPI protocol support for collaborative editing

Implement the Web Application Open Platform Interface (WOPI) protocol
to enable collaborative document editing with Collabora Online and
OnlyOffice through OxiCloud.

Backend:
- WOPI token service with HMAC-SHA256 signed access tokens
- WOPI lock service with in-memory lock management and expiry
- WOPI discovery service for auto-detecting editor capabilities
- WOPI HTTP handler: CheckFileInfo, GetFile, PutFile, Lock/Unlock
- File entity extended with owner_id for WOPI file-info responses
- Configuration via WOPI_* environment variables
- Services wired through DI in AppState

Frontend:
- WOPI editor component with modal and new-tab viewing modes
- Context menu integration for opening files in online editors
- Inline viewer integration for document preview

Infrastructure:
- Docker Compose file for local Collabora/OnlyOffice dev setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jan Wiebe
2026-02-21 13:39:27 +01:00
parent 807370e194
commit 4e2c9d2592
20 changed files with 1877 additions and 14 deletions
@@ -61,6 +61,7 @@ impl FileBlobReadRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
owner_id: Option<String>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps(
@@ -72,6 +73,7 @@ impl FileBlobReadRepository {
folder_id,
created_at as u64,
modified_at as u64,
owner_id,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
@@ -110,6 +112,7 @@ impl FileReadPort for FileBlobReadRepository {
i64, // created_at
i64, // updated_at
String, // blob_hash
Option<String>, // user_id (owner)
),
>(
r#"
@@ -117,7 +120,8 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash
fi.blob_hash,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.id = $1::uuid AND NOT fi.is_trashed
@@ -136,7 +140,9 @@ impl FileReadPort for FileBlobReadRepository {
.unwrap()
.insert(id.to_string(), row.8.clone());
Self::row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7)
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.9,
)
}
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
@@ -149,13 +155,15 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
Option<String>,
)> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
@@ -171,7 +179,8 @@ impl FileReadPort for FileBlobReadRepository {
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -184,8 +193,8 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma)
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
})
.collect()
}
@@ -308,13 +317,15 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
Option<String>,
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -336,13 +347,15 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
Option<String>,
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed
@@ -357,7 +370,7 @@ impl FileReadPort for FileBlobReadRepository {
match row {
Some(r) => Ok(Some(Self::row_to_file(
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7,
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8,
)?)),
None => Ok(None),
}
@@ -83,6 +83,7 @@ impl FileBlobWriteRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
owner_id: Option<String>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps(
@@ -94,6 +95,7 @@ impl FileBlobWriteRepository {
folder_id,
created_at as u64,
modified_at as u64,
owner_id,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
@@ -190,6 +192,7 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
)
}
@@ -272,6 +275,7 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
)
}
@@ -299,7 +303,17 @@ impl FileWritePort for FileBlobWriteRepository {
.ok_or_else(|| DomainError::not_found("File", file_id))?;
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
Self::row_to_file(
row.0,
row.1,
row.2,
folder_path,
row.3,
row.4,
row.5,
row.6,
None,
)
}
async fn copy_file(
@@ -382,7 +396,17 @@ impl FileWritePort for FileBlobWriteRepository {
);
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
Self::row_to_file(
row.0,
row.1,
row.2,
folder_path,
row.3,
row.4,
row.5,
row.6,
None,
)
}
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
@@ -411,7 +435,17 @@ impl FileWritePort for FileBlobWriteRepository {
.ok_or_else(|| DomainError::not_found("File", file_id))?;
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
Self::row_to_file(
row.0,
row.1,
row.2,
folder_path,
row.3,
row.4,
row.5,
row.6,
None,
)
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
@@ -543,6 +577,7 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
)?;
// The target_path is not meaningful for blob storage (content goes to .blobs/)
+1
View File
@@ -10,4 +10,5 @@ pub mod password_hasher;
pub mod path_service;
pub mod thumbnail_service;
pub mod trash_cleanup_service;
pub mod wopi_discovery_service;
pub mod zip_service;
@@ -0,0 +1,362 @@
//! 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<WopiAction>
actions: Arc<RwLock<HashMap<String, Vec<WopiAction>>>>,
last_fetched: Arc<RwLock<Option<Instant>>>,
cache_ttl: Duration,
/// HTTP client with timeout (shared across requests).
http_client: reqwest::Client,
/// Mutex to prevent concurrent refresh stampede.
refreshing: Arc<tokio::sync::Mutex<()>>,
}
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<Option<String>, 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<bool, DomainError> {
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<Vec<String>, 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<HashMap<String, Vec<WopiAction>>, DomainError> {
let mut reader = Reader::from_str(xml);
let mut actions: HashMap<String, Vec<WopiAction>> = 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() {
match attr.key.as_ref() {
b"name" => name = String::from_utf8_lossy(&attr.value).to_string(),
b"ext" => ext = String::from_utf8_lossy(&attr.value).to_string(),
b"urlsrc" => urlsrc = String::from_utf8_lossy(&attr.value).to_string(),
_ => {}
}
}
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#"<?xml version="1.0" encoding="utf-8"?>
<wopi-discovery>
<net-zone name="external-https">
<app name="Word">
<action name="view" ext="docx" urlsrc="https://collabora/cool/word/view?WOPISrc=WOPI_SOURCE&amp;lang=UI_LLCC"/>
<action name="edit" ext="docx" urlsrc="https://collabora/cool/word/edit?WOPISrc=WOPI_SOURCE&amp;lang=UI_LLCC"/>
</app>
<app name="Excel">
<action name="edit" ext="xlsx" urlsrc="https://collabora/cool/calc/edit?WOPISrc=WOPI_SOURCE"/>
</app>
<app name="Impress">
<action name="view" ext="pptx" urlsrc="https://collabora/cool/impress/view?WOPISrc=WOPI_SOURCE"/>
</app>
</net-zone>
</wopi-discovery>"#;
#[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"));
}
#[test]
fn test_clean_placeholder_params() {
let url =
"https://example.com/edit?WOPISrc=http%3A%2F%2Flocalhost&<lang=UI_LLCC&><ui=UI_LLCC&>";
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"));
}
}