From 4e2c9d259287163f7a495f15704c946cd97fa27e Mon Sep 17 00:00:00 2001 From: Jan Wiebe Date: Sat, 21 Feb 2026 13:39:27 +0100 Subject: [PATCH] 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 --- docker-compose.wopi.yml | 31 ++ src/application/dtos/file_dto.rs | 6 + src/application/services/mod.rs | 2 + src/application/services/wopi_lock_service.rs | 234 ++++++++ .../services/wopi_token_service.rs | 175 ++++++ src/common/config.rs | 61 +- src/common/di.rs | 55 ++ src/domain/entities/file.rs | 16 + .../pg/file_blob_read_repository.rs | 31 +- .../pg/file_blob_write_repository.rs | 41 +- src/infrastructure/services/mod.rs | 1 + .../services/wopi_discovery_service.rs | 362 ++++++++++++ src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/wopi_handler.rs | 526 ++++++++++++++++++ src/main.rs | 51 ++ static/index.html | 1 + static/js/app/ui.js | 17 + static/js/features/files/contextMenus.js | 35 +- static/js/features/files/inlineViewer.js | 7 + static/js/features/files/wopiEditor.js | 238 ++++++++ 20 files changed, 1877 insertions(+), 14 deletions(-) create mode 100644 docker-compose.wopi.yml create mode 100644 src/application/services/wopi_lock_service.rs create mode 100644 src/application/services/wopi_token_service.rs create mode 100644 src/infrastructure/services/wopi_discovery_service.rs create mode 100644 src/interfaces/api/handlers/wopi_handler.rs create mode 100644 static/js/features/files/wopiEditor.js diff --git a/docker-compose.wopi.yml b/docker-compose.wopi.yml new file mode 100644 index 00000000..ac963e5b --- /dev/null +++ b/docker-compose.wopi.yml @@ -0,0 +1,31 @@ +# WOPI editor services for local development. +# Usage: docker compose -f docker-compose.dev.yml -f docker-compose.wopi.yml up -d +# Then run OxiCloud natively: cargo run + +services: + collabora: + image: collabora/code:latest + restart: unless-stopped + cap_add: + - MKNOD + environment: + # Allow OxiCloud running on host to use Collabora + - "aliasgroup1=http://host.docker.internal:8086" + # Disable SSL and SSL termination (dev only, plain HTTP on localhost) + - "extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:net.frame_ancestors=http://localhost:* http://127.0.0.1:*" + # Admin console (optional) + - "username=admin" + - "password=admin" + ports: + - "9980:9980" + + # Uncomment to use OnlyOffice instead of / alongside Collabora: + # onlyoffice: + # image: onlyoffice/documentserver:latest + # restart: unless-stopped + # environment: + # - "WOPI_ENABLED=true" + # - "JWT_SECRET=oxicloud-dev-secret" + # - "JWT_ENABLED=true" + # ports: + # - "8088:80" diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index ca955c26..2f05e6fb 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -44,6 +44,10 @@ pub struct FileDto { /// Human-readable formatted size (e.g. "3.27 MB") pub size_formatted: String, + + /// Owner user ID (omitted from JSON when None) + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_id: Option, } impl From for FileDto { @@ -65,6 +69,7 @@ impl From for FileDto { icon_special_class: icon_special_class_for(name, mime).to_string(), category: category_for(name, mime).to_string(), size_formatted: format_file_size(size), + owner_id: file.owner_id().map(String::from), } } } @@ -103,6 +108,7 @@ impl FileDto { icon_special_class: String::new(), category: "Document".to_string(), size_formatted: "0 Bytes".to_string(), + owner_id: None, } } } diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index f96cf8e3..de320ebb 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -15,6 +15,8 @@ pub mod search_service; pub mod share_service; pub mod storage_usage_service; pub mod trash_service; +pub mod wopi_lock_service; +pub mod wopi_token_service; #[cfg(test)] mod trash_service_test; diff --git a/src/application/services/wopi_lock_service.rs b/src/application/services/wopi_lock_service.rs new file mode 100644 index 00000000..043d81f8 --- /dev/null +++ b/src/application/services/wopi_lock_service.rs @@ -0,0 +1,234 @@ +//! In-memory WOPI lock service. +//! +//! Manages file locks required by the WOPI protocol for concurrent editing. +//! Uses an in-memory HashMap — suitable for single-instance deployments. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// A lock entry for a file. +#[derive(Debug, Clone)] +struct LockEntry { + lock_id: String, + expires_at: Instant, +} + +/// Error returned when a lock operation conflicts. +#[derive(Debug)] +pub struct LockConflict { + /// The lock ID currently held on the file + pub existing_lock_id: String, +} + +/// In-memory WOPI lock manager. +#[derive(Clone)] +pub struct WopiLockService { + locks: Arc>>, + lock_duration: Duration, +} + +impl WopiLockService { + pub fn new(lock_ttl_secs: u64) -> Self { + Self { + locks: Arc::new(RwLock::new(HashMap::new())), + lock_duration: Duration::from_secs(lock_ttl_secs), + } + } + + /// Lock a file. If already locked with the same lock_id, refreshes the timer. + pub async fn lock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict> { + let mut locks = self.locks.write().await; + if let Some(entry) = locks.get(file_id) { + if entry.lock_id == lock_id || entry.expires_at <= Instant::now() { + // Same lock or expired — allow + } else { + return Err(LockConflict { + existing_lock_id: entry.lock_id.clone(), + }); + } + } + locks.insert( + file_id.to_string(), + LockEntry { + lock_id: lock_id.to_string(), + expires_at: Instant::now() + self.lock_duration, + }, + ); + Ok(()) + } + + /// Unlock a file. The lock_id must match. + pub async fn unlock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict> { + let mut locks = self.locks.write().await; + if let Some(entry) = locks.get(file_id) + && entry.lock_id != lock_id + && entry.expires_at > Instant::now() + { + return Err(LockConflict { + existing_lock_id: entry.lock_id.clone(), + }); + } + locks.remove(file_id); + Ok(()) + } + + /// Refresh the lock timer. The file must be locked with the given lock_id. + pub async fn refresh_lock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict> { + let mut locks = self.locks.write().await; + match locks.get(file_id) { + None => { + // No lock exists — WOPI spec requires 409 with empty lock + return Err(LockConflict { + existing_lock_id: String::new(), + }); + } + Some(entry) if entry.expires_at <= Instant::now() => { + // Lock expired — treat as unlocked + locks.remove(file_id); + return Err(LockConflict { + existing_lock_id: String::new(), + }); + } + Some(entry) if entry.lock_id != lock_id => { + // Different lock holder + return Err(LockConflict { + existing_lock_id: entry.lock_id.clone(), + }); + } + Some(_) => { + // Matching lock — refresh the timer + } + } + locks.insert( + file_id.to_string(), + LockEntry { + lock_id: lock_id.to_string(), + expires_at: Instant::now() + self.lock_duration, + }, + ); + Ok(()) + } + + /// Get the current lock ID for a file, if locked. + pub async fn get_lock(&self, file_id: &str) -> Option { + let locks = self.locks.read().await; + locks.get(file_id).and_then(|entry| { + if entry.expires_at > Instant::now() { + Some(entry.lock_id.clone()) + } else { + None + } + }) + } + + /// Remove expired locks. Call this periodically. + pub async fn cleanup_expired(&self) { + let mut locks = self.locks.write().await; + let now = Instant::now(); + locks.retain(|_, entry| entry.expires_at > now); + } + + /// Start a background task that cleans up expired locks every 60 seconds. + pub fn start_cleanup_task(self: &Arc) { + let service = Arc::clone(self); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + service.cleanup_expired().await; + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_lock_and_unlock() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + assert_eq!(svc.get_lock("file-1").await, Some("lock-abc".to_string())); + svc.unlock("file-1", "lock-abc") + .await + .expect("Should unlock"); + assert_eq!(svc.get_lock("file-1").await, None); + } + + #[tokio::test] + async fn test_lock_conflict() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + let result = svc.lock("file-1", "lock-xyz").await; + assert!(result.is_err()); + let conflict = result.unwrap_err(); + assert_eq!(conflict.existing_lock_id, "lock-abc"); + } + + #[tokio::test] + async fn test_same_lock_refreshes() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + svc.lock("file-1", "lock-abc") + .await + .expect("Same lock should succeed"); + } + + #[tokio::test] + async fn test_refresh_lock() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + svc.refresh_lock("file-1", "lock-abc") + .await + .expect("Should refresh"); + assert_eq!(svc.get_lock("file-1").await, Some("lock-abc".to_string())); + } + + #[tokio::test] + async fn test_unlock_conflict() { + let svc = WopiLockService::new(1800); + svc.lock("file-1", "lock-abc").await.expect("Should lock"); + let result = svc.unlock("file-1", "wrong-lock").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_lock_returns_none_for_unlocked() { + let svc = WopiLockService::new(1800); + assert_eq!(svc.get_lock("file-1").await, None); + } + + #[tokio::test] + async fn test_refresh_lock_on_unlocked_file_returns_conflict() { + let svc = WopiLockService::new(1800); + let result = svc.refresh_lock("file-1", "lock-abc").await; + assert!(result.is_err()); + let conflict = result.unwrap_err(); + assert_eq!(conflict.existing_lock_id, ""); + } + + #[tokio::test] + async fn test_refresh_lock_on_expired_lock_returns_conflict() { + let svc = WopiLockService::new(0); // 0 seconds = immediate expiry + svc.lock("file-1", "lock-old").await.expect("Should lock"); + tokio::time::sleep(Duration::from_millis(10)).await; + let result = svc.refresh_lock("file-1", "lock-old").await; + assert!(result.is_err()); + let conflict = result.unwrap_err(); + assert_eq!(conflict.existing_lock_id, ""); + } + + #[tokio::test] + async fn test_expired_lock_allows_new_lock() { + let svc = WopiLockService::new(0); // 0 seconds = immediate expiry + svc.lock("file-1", "lock-old").await.expect("Should lock"); + tokio::time::sleep(Duration::from_millis(10)).await; + // Expired lock should not block a new lock from a different holder + svc.lock("file-1", "lock-new") + .await + .expect("Expired lock should allow new lock"); + } +} diff --git a/src/application/services/wopi_token_service.rs b/src/application/services/wopi_token_service.rs new file mode 100644 index 00000000..4c2e9d36 --- /dev/null +++ b/src/application/services/wopi_token_service.rs @@ -0,0 +1,175 @@ +//! WOPI access token service. +//! +//! Generates and validates WOPI-scoped JWT tokens that are separate from +//! the regular authentication tokens. Uses the same `jsonwebtoken` crate +//! but with a distinct `scope: "wopi"` claim to prevent token confusion. + +use chrono::Utc; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; + +use crate::common::errors::{DomainError, ErrorKind}; + +/// JWT claims for WOPI access tokens. +#[derive(Debug, Serialize, Deserialize)] +pub struct WopiTokenClaims { + /// User ID + pub sub: String, + /// File ID this token grants access to + pub file_id: String, + /// Whether the user can write (edit) the file + pub can_write: bool, + /// Token scope — always "wopi" to distinguish from auth tokens + pub scope: String, + /// Display name for the editor UI + pub username: String, + /// Expiration timestamp (seconds since Unix epoch) + pub exp: i64, + /// Issued at timestamp + pub iat: i64, +} + +/// Service for generating and validating WOPI access tokens. +pub struct WopiTokenService { + secret: String, + token_ttl_secs: i64, +} + +impl WopiTokenService { + pub fn new(secret: String, token_ttl_secs: i64) -> Self { + Self { + secret, + token_ttl_secs, + } + } + + /// Generate a WOPI access token for a specific file and user. + /// + /// Returns `(token_string, expiration_unix_ms)`. + pub fn generate_token( + &self, + file_id: &str, + user_id: &str, + username: &str, + can_write: bool, + ) -> Result<(String, i64), DomainError> { + let now = Utc::now().timestamp(); + let claims = WopiTokenClaims { + sub: user_id.to_string(), + file_id: file_id.to_string(), + can_write, + scope: "wopi".to_string(), + username: username.to_string(), + exp: now + self.token_ttl_secs, + iat: now, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.secret.as_bytes()), + ) + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "WopiTokenService", + format!("Failed to generate WOPI token: {}", e), + ) + })?; + + let expires_at_unix_ms = claims.exp * 1000; + Ok((token, expires_at_unix_ms)) + } + + /// Validate a WOPI access token and extract its claims. + pub fn validate_token(&self, token: &str) -> Result { + let validation = Validation::new(Algorithm::HS256); + + let token_data = decode::( + token, + &DecodingKey::from_secret(self.secret.as_bytes()), + &validation, + ) + .map_err(|e| match e.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + "WOPI token expired", + ), + _ => DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + format!("Invalid WOPI token: {}", e), + ), + })?; + + let claims = token_data.claims; + + if claims.scope != "wopi" { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "WopiTokenService", + "Token is not a WOPI token", + )); + } + + Ok(claims) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service() -> WopiTokenService { + WopiTokenService::new("test_secret_at_least_32_bytes_long!!".to_string(), 3600) + } + + #[test] + fn test_generate_and_validate() { + let svc = service(); + let (token, ttl_ms) = svc + .generate_token("file-123", "user-456", "test_user", true) + .expect("Should generate token"); + + let claims = svc.validate_token(&token).expect("Should validate"); + assert_eq!(claims.file_id, "file-123"); + assert_eq!(claims.sub, "user-456"); + assert!(claims.can_write); + assert_eq!(claims.scope, "wopi"); + assert_eq!(claims.username, "test_user"); + + // access_token_ttl must be absolute UNIX time in milliseconds. + assert_eq!(ttl_ms, claims.exp * 1000); + assert!(ttl_ms > claims.iat * 1000); + } + + #[test] + fn test_reject_invalid_token() { + let svc = service(); + let result = svc.validate_token("garbage"); + assert!(result.is_err()); + } + + #[test] + fn test_reject_wrong_secret() { + let svc1 = service(); + let svc2 = WopiTokenService::new("different_secret_also_32_bytes!!".to_string(), 3600); + + let (token, _) = svc1 + .generate_token("file-1", "user-1", "test_user", false) + .expect("Should generate"); + let result = svc2.validate_token(&token); + assert!(result.is_err()); + } + + #[test] + fn test_read_only_token() { + let svc = service(); + let (token, _) = svc + .generate_token("file-1", "user-1", "test_user", false) + .expect("Should generate"); + let claims = svc.validate_token(&token).expect("Should validate"); + assert!(!claims.can_write); + } +} diff --git a/src/common/config.rs b/src/common/config.rs index 2017ec28..b6e076cd 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -228,7 +228,7 @@ impl Default for DatabaseConfig { fn default() -> Self { Self { // Updated connection string with default credentials that PostgreSQL often uses - connection_string: "postgres://postgres:postgres@localhost:5432/oxicloud".to_string(), + connection_string: "postgres://postgres:postgres@localhost:5439/oxicloud".to_string(), max_connections: 20, min_connections: 5, connect_timeout_secs: 10, @@ -350,6 +350,35 @@ impl OidcConfig { } } +/// WOPI (Web Application Open Platform Interface) configuration +#[derive(Debug, Clone)] +pub struct WopiConfig { + /// Whether WOPI integration is enabled + pub enabled: bool, + /// URL to the WOPI client's discovery endpoint + /// e.g., "http://collabora:9980/hosting/discovery" + pub discovery_url: String, + /// Secret key for signing WOPI access tokens + /// Falls back to JWT secret if empty + pub secret: String, + /// Access token TTL in seconds (default: 86400 = 24 hours) + pub token_ttl_secs: i64, + /// Lock expiration in seconds (default: 1800 = 30 minutes) + pub lock_ttl_secs: u64, +} + +impl Default for WopiConfig { + fn default() -> Self { + Self { + enabled: false, + discovery_url: String::new(), + secret: String::new(), + token_ttl_secs: 86400, + lock_ttl_secs: 1800, + } + } +} + /// Feature configuration (feature flags) #[derive(Debug, Clone)] pub struct FeaturesConfig { @@ -401,6 +430,8 @@ pub struct AppConfig { pub features: FeaturesConfig, /// OIDC configuration pub oidc: OidcConfig, + /// WOPI configuration + pub wopi: WopiConfig, } impl Default for AppConfig { @@ -419,6 +450,7 @@ impl Default for AppConfig { auth: AuthConfig::default(), features: FeaturesConfig::default(), oidc: OidcConfig::default(), + wopi: WopiConfig::default(), } } } @@ -581,6 +613,33 @@ impl AppConfig { config.oidc.enabled = false; } + // WOPI configuration + if let Ok(v) = env::var("OXICLOUD_WOPI_ENABLED") { + config.wopi.enabled = v.parse::().unwrap_or(false); + } + if let Ok(v) = env::var("OXICLOUD_WOPI_DISCOVERY_URL") { + config.wopi.discovery_url = v; + } + if let Ok(v) = env::var("OXICLOUD_WOPI_SECRET") { + config.wopi.secret = v; + } + if let Ok(v) = env::var("OXICLOUD_WOPI_TOKEN_TTL_SECS") + && let Ok(val) = v.parse::() + { + config.wopi.token_ttl_secs = val; + } + if let Ok(v) = env::var("OXICLOUD_WOPI_LOCK_TTL_SECS") + && let Ok(val) = v.parse::() + { + config.wopi.lock_ttl_secs = val; + } + + // WOPI secret fallback: use JWT secret if WOPI secret not set + if config.wopi.enabled && config.wopi.secret.is_empty() { + config.wopi.secret = config.auth.jwt_secret.clone(); + tracing::info!("WOPI secret not set, falling back to JWT secret"); + } + config } diff --git a/src/common/di.rs b/src/common/di.rs index 768c6557..32543d07 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -515,6 +515,9 @@ impl AppServiceFactory { calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, + wopi_token_service: None, + wopi_lock_service: None, + wopi_discovery_service: None, }; // 9b. Wire admin settings service when auth is available @@ -632,6 +635,46 @@ impl AppServiceFactory { tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); } + // 11. Wire WOPI services if enabled + if self.config.wopi.enabled { + let discovery_url = &self.config.wopi.discovery_url; + if discovery_url.is_empty() { + tracing::error!( + "WOPI is enabled but WOPI_DISCOVERY_URL is empty — WOPI services will NOT be available" + ); + } else { + use crate::application::services::wopi_lock_service::WopiLockService; + use crate::application::services::wopi_token_service::WopiTokenService; + use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; + + let wopi_secret = if self.config.wopi.secret.is_empty() { + self.config.auth.jwt_secret.clone() + } else { + self.config.wopi.secret.clone() + }; + + let wopi_token_service = Arc::new(WopiTokenService::new( + wopi_secret, + self.config.wopi.token_ttl_secs, + )); + + let wopi_lock_service = + Arc::new(WopiLockService::new(self.config.wopi.lock_ttl_secs)); + wopi_lock_service.start_cleanup_task(); + + let wopi_discovery_service = Arc::new(WopiDiscoveryService::new( + discovery_url.clone(), + 86400, // 24 hour cache TTL + )); + + app_state.wopi_token_service = Some(wopi_token_service); + app_state.wopi_lock_service = Some(wopi_lock_service); + app_state.wopi_discovery_service = Some(wopi_discovery_service); + + tracing::info!("WOPI services initialized (discovery: {})", discovery_url); + } + } + Ok(app_state) } } @@ -710,6 +753,12 @@ pub struct AppState { pub addressbook_use_case: Option>, pub contact_use_case: Option>, + pub wopi_token_service: + Option>, + pub wopi_lock_service: + Option>, + pub wopi_discovery_service: + Option>, } impl Default for AppState { @@ -844,6 +893,9 @@ impl Default for AppState { calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, + wopi_token_service: None, + wopi_lock_service: None, + wopi_discovery_service: None, } } } @@ -871,6 +923,9 @@ impl AppState { calendar_use_case: None, addressbook_use_case: None, contact_use_case: None, + wopi_token_service: None, + wopi_lock_service: None, + wopi_discovery_service: None, } } diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index a353d438..93458330 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -41,6 +41,9 @@ pub struct File { /// Last modification timestamp (seconds since UNIX epoch) modified_at: u64, + + /// Owner user ID (from storage.files.user_id) + owner_id: Option, } // We no longer need this module, now we use a String directly @@ -57,6 +60,7 @@ impl Default for File { folder_id: None, created_at: 0, modified_at: 0, + owner_id: None, } } } @@ -94,6 +98,7 @@ impl File { folder_id, created_at: now, modified_at: now, + owner_id: None, }) } @@ -124,6 +129,7 @@ impl File { folder_id: parent_id, created_at, modified_at, + owner_id: None, }) } @@ -137,6 +143,7 @@ impl File { folder_id: Option, created_at: u64, modified_at: u64, + owner_id: Option, ) -> FileResult { // Validate file name if name.is_empty() || name.contains('/') || name.contains('\\') { @@ -156,6 +163,7 @@ impl File { folder_id, created_at, modified_at, + owner_id, }) } @@ -196,6 +204,10 @@ impl File { self.modified_at } + pub fn owner_id(&self) -> Option<&str> { + self.owner_id.as_deref() + } + #[allow(clippy::too_many_arguments)] pub fn from_dto( id: String, @@ -221,6 +233,7 @@ impl File { folder_id, created_at, modified_at, + owner_id: None, } } @@ -258,6 +271,7 @@ impl File { folder_id: self.folder_id.clone(), created_at: self.created_at, modified_at: now, + owner_id: self.owner_id.clone(), }) } @@ -291,6 +305,7 @@ impl File { folder_id, created_at: self.created_at, modified_at: now, + owner_id: self.owner_id.clone(), }) } @@ -311,6 +326,7 @@ impl File { folder_id: self.folder_id.clone(), created_at: self.created_at, modified_at: now, + owner_id: self.owner_id.clone(), } } } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 00d956ec..defd82f7 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -61,6 +61,7 @@ impl FileBlobReadRepository { mime_type: String, created_at: i64, modified_at: i64, + owner_id: Option, ) -> Result { 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, // 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, DomainError> { @@ -149,13 +155,15 @@ impl FileReadPort for FileBlobReadRepository { String, i64, i64, + Option, )> = 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, ), >( 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, ), >( 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), } diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 7eecb9d4..72e458cc 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -83,6 +83,7 @@ impl FileBlobWriteRepository { mime_type: String, created_at: i64, modified_at: i64, + owner_id: Option, ) -> Result { 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 { @@ -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/) diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 002247b2..3efe9479 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/wopi_discovery_service.rs b/src/infrastructure/services/wopi_discovery_service.rs new file mode 100644 index 00000000..d617cc7f --- /dev/null +++ b/src/infrastructure/services/wopi_discovery_service.rs @@ -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 + 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() { + 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#" + + + + + + + + + + + + + +"#; + + #[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&"; + 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")); + } +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 52f0e1d0..c0abb3d4 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -14,6 +14,7 @@ pub mod search_handler; pub mod share_handler; pub mod trash_handler; pub mod webdav_handler; +pub mod wopi_handler; /// Tipo de resultado para controladores de API pub type ApiResult = Result; diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs new file mode 100644 index 00000000..6224a5a2 --- /dev/null +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -0,0 +1,526 @@ +//! WOPI protocol handler. +//! +//! Implements the WOPI host endpoints called by document editors +//! (Collabora Online, OnlyOffice) to access and modify files. +//! +//! These endpoints use `?access_token=` query parameter auth, NOT the +//! regular JWT auth middleware. +//! +//! Reference: doc/wopi-integration.md + +use crate::interfaces::middleware::auth::AuthUser; +use axum::{ + Router, + body::Bytes, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::application::services::wopi_lock_service::WopiLockService; +use crate::application::services::wopi_token_service::WopiTokenService; +use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; + +/// Shared state for WOPI handlers. +#[derive(Clone)] +pub struct WopiState { + pub token_service: Arc, + pub lock_service: Arc, + pub discovery_service: Arc, + pub app_state: crate::common::di::AppState, + /// Public base URL for host page origin and postMessage origin + pub public_base_url: String, + /// Base URL used for WOPISrc callbacks from Collabora to OxiCloud + pub wopi_base_url: String, +} + +/// Query parameter for WOPI access token. +#[derive(Deserialize)] +pub struct WopiTokenQuery { + pub access_token: String, +} + +/// CheckFileInfo response (WOPI spec). +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct CheckFileInfoResponse { + pub base_file_name: String, + pub owner_id: String, + pub size: u64, + pub user_id: String, + pub version: String, + pub supports_locks: bool, + pub supports_update: bool, + pub supports_rename: bool, + pub user_can_write: bool, + pub user_friendly_name: String, + pub post_message_origin: String, + pub last_modified_time: String, + pub close_url: String, +} + +/// GET /wopi/files/{file_id} — CheckFileInfo +async fn check_file_info( + Path(file_id): Path, + Query(token_query): Query, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // Fetch file metadata + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(&file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + // Convert u64 timestamp to RFC 3339 string + let last_modified = chrono::DateTime::from_timestamp(file.modified_at as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + + let response = CheckFileInfoResponse { + base_file_name: file.name.clone(), + owner_id: file.owner_id.clone().unwrap_or_else(|| claims.sub.clone()), + size: file.size, + user_id: claims.sub.clone(), + version: file.modified_at.to_string(), + supports_locks: true, + supports_update: claims.can_write, + supports_rename: false, + user_can_write: claims.can_write, + user_friendly_name: claims.username.clone(), + post_message_origin: state.public_base_url.clone(), + last_modified_time: last_modified, + close_url: state.public_base_url.clone(), + }; + + axum::Json(response).into_response() +} + +/// GET /wopi/files/{file_id}/contents — GetFile +async fn get_file( + Path(file_id): Path, + Query(token_query): Query, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + match state + .app_state + .applications + .file_retrieval_service + .get_file_content(&file_id) + .await + { + Ok(content) => (StatusCode::OK, content).into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +/// POST /wopi/files/{file_id}/contents — PutFile +async fn put_file( + Path(file_id): Path, + Query(token_query): Query, + headers: HeaderMap, + State(state): State, + body: Bytes, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id || !claims.can_write { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // Check lock + let request_lock = headers + .get("X-WOPI-Lock") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let current_lock = state.lock_service.get_lock(&file_id).await; + + if let Some(ref current) = current_lock { + match &request_lock { + Some(req_lock) if req_lock == current => { + // Lock matches — proceed + } + _ => { + // Lock mismatch + return ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", current.as_str())], + "Lock mismatch", + ) + .into_response(); + } + } + } + + // Get file path for update_file + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(&file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + // Save the file content using path-based update + match state + .app_state + .applications + .file_upload_service + .update_file(&file.path, &body) + .await + { + Ok(_) => StatusCode::OK.into_response(), + Err(e) => { + tracing::error!("WOPI PutFile failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// POST /wopi/files/{file_id} — Dispatches lock operations based on X-WOPI-Override header +async fn file_operations( + Path(file_id): Path, + Query(token_query): Query, + headers: HeaderMap, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let override_header = headers + .get("X-WOPI-Override") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let lock_id = headers + .get("X-WOPI-Lock") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + match override_header { + "LOCK" => { + if lock_id.is_empty() { + return StatusCode::BAD_REQUEST.into_response(); + } + match state.lock_service.lock(&file_id, lock_id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(conflict) => ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", conflict.existing_lock_id.as_str())], + "", + ) + .into_response(), + } + } + "UNLOCK" => match state.lock_service.unlock(&file_id, lock_id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(conflict) => ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", conflict.existing_lock_id.as_str())], + "", + ) + .into_response(), + }, + "REFRESH_LOCK" => match state.lock_service.refresh_lock(&file_id, lock_id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(conflict) => ( + StatusCode::CONFLICT, + [("X-WOPI-Lock", conflict.existing_lock_id.as_str())], + "", + ) + .into_response(), + }, + "GET_LOCK" => { + let current = state.lock_service.get_lock(&file_id).await; + let lock_val = current.unwrap_or_default(); + (StatusCode::OK, [("X-WOPI-Lock", lock_val.as_str())], "").into_response() + } + _ => (StatusCode::NOT_IMPLEMENTED, "Unknown WOPI override").into_response(), + } +} + +/// Parameters for the editor URL API endpoint. +#[derive(Deserialize)] +pub struct EditorUrlParams { + pub file_id: String, + #[serde(default = "default_action")] + pub action: String, +} + +fn default_action() -> String { + "edit".to_string() +} + +/// Response from the editor URL API endpoint. +#[derive(Serialize)] +pub struct EditorUrlResponse { + pub editor_url: String, + pub access_token: String, + pub access_token_ttl: i64, +} + +/// GET /api/wopi/editor-url — Returns the editor iframe URL + WOPI token. +/// +/// This endpoint is behind normal auth middleware. The authenticated user +/// requests a WOPI session for a specific file. +pub async fn get_editor_url( + AuthUser { + id: user_id, + username, + }: AuthUser, + Query(params): Query, + State(state): State, +) -> Response { + // Get file info to determine extension + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(¶ms.file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + // Extract extension from filename + let extension = file.name.rsplit('.').next().unwrap_or("").to_lowercase(); + + // Build WOPISrc + let wopi_src = format!("{}/wopi/files/{}", state.wopi_base_url, params.file_id); + + // Get editor action URL from discovery + let editor_url = match state + .discovery_service + .get_action_url(&extension, ¶ms.action, &wopi_src) + .await + { + Ok(Some(url)) => url, + Ok(None) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + format!("No editor available for .{} files", extension), + ) + .into_response(); + } + Err(e) => { + tracing::error!("WOPI discovery error: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + // Determine write permission: owner can write, others read-only. + // If no owner_id on the file, default to allowing write. + let can_write = match &file.owner_id { + Some(owner) => owner == &user_id, + None => true, + }; + + // Generate WOPI access token + let (access_token, access_token_ttl) = + match state + .token_service + .generate_token(¶ms.file_id, &user_id, &username, can_write) + { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to generate WOPI token: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + axum::Json(EditorUrlResponse { + editor_url, + access_token, + access_token_ttl, + }) + .into_response() +} + +/// GET /wopi/edit/{file_id} — Server-rendered host page for new-tab editing. +/// +/// Returns a minimal HTML page that POSTs the access token to the editor iframe. +async fn host_page( + Path(file_id): Path, + Query(token_query): Query, + State(state): State, +) -> Response { + let claims = match state + .token_service + .validate_token(&token_query.access_token) + { + Ok(c) => c, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + + if claims.file_id != file_id { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // Get file info for extension + let file = match state + .app_state + .applications + .file_retrieval_service + .get_file(&file_id) + .await + { + Ok(f) => f, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + let extension = file.name.rsplit('.').next().unwrap_or("").to_lowercase(); + let action = if claims.can_write { "edit" } else { "view" }; + let wopi_src = format!("{}/wopi/files/{}", state.wopi_base_url, file_id); + + let editor_url = match state + .discovery_service + .get_action_url(&extension, action, &wopi_src) + .await + { + Ok(Some(url)) => url, + _ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + let (token, ttl) = match state.token_service.generate_token( + &file_id, + &claims.sub, + &claims.username, + claims.can_write, + ) { + Ok(t) => t, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + // Escape HTML entities in file name + let safe_name = file + .name + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """); + + let html = format!( + r#" + + + + {safe_name} - OxiCloud Editor + + + +
+ + +
+ + + +"# + ); + + Html(html).into_response() +} + +/// GET /wopi/supported-extensions — Returns extensions the editor supports. +/// +/// Public endpoint (no auth) so the frontend can dynamically show/hide +/// the "Edit in Office" context menu option. +async fn get_supported_extensions(State(state): State) -> Response { + match state.discovery_service.get_supported_extensions().await { + Ok(exts) => axum::Json(exts).into_response(), + Err(e) => { + tracing::error!("Failed to get supported extensions: {}", e); + axum::Json(Vec::::new()).into_response() + } + } +} + +/// Build all WOPI routes. +/// +/// Returns a tuple: (wopi_protocol_router, wopi_api_router) +/// - wopi_protocol_router: mounted at `/wopi` (no auth middleware) +/// - wopi_api_router: mounted at `/api/wopi` (behind auth middleware) +pub fn wopi_routes( + wopi_state: WopiState, +) -> ( + Router, + Router, +) { + let protocol_router = Router::new() + // CheckFileInfo + .route("/files/{file_id}", get(check_file_info)) + // Lock/Unlock/RefreshLock/GetLock + .route("/files/{file_id}", post(file_operations)) + // GetFile + .route("/files/{file_id}/contents", get(get_file)) + // PutFile + .route("/files/{file_id}/contents", post(put_file)) + // Host page for new-tab editing + .route("/edit/{file_id}", get(host_page)) + // Supported extensions (public, no auth) + .route("/supported-extensions", get(get_supported_extensions)) + .with_state(wopi_state.clone()); + + let api_router = Router::new() + .route("/editor-url", get(get_editor_url)) + .with_state(wopi_state); + + (protocol_router, api_router) +} diff --git a/src/main.rs b/src/main.rs index d3f5b9f1..e24daa77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,9 @@ use interfaces::{create_api_routes, create_public_api_routes, web::create_web_ro #[tokio::main] async fn main() -> Result<(), Box> { + // Load .env file if present (for local development) + dotenvy::dotenv().ok(); + // Initialize tracing tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( @@ -98,6 +101,38 @@ async fn main() -> Result<(), Box> { let carddav_router = carddav_handler::carddav_routes(); let webdav_router = webdav_handler::webdav_routes(); + // Build WOPI routes if enabled + use oxicloud::interfaces::api::handlers::wopi_handler; + let wopi_routes = if config.wopi.enabled { + if let (Some(token_svc), Some(lock_svc), Some(discovery_svc)) = ( + &app_state.wopi_token_service, + &app_state.wopi_lock_service, + &app_state.wopi_discovery_service, + ) { + let wopi_base_url = std::env::var("OXICLOUD_WOPI_BASE_URL") + .map(|v| v.trim_end_matches('/').to_string()) + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| config.base_url()); + + let wopi_state = wopi_handler::WopiState { + token_service: token_svc.clone(), + lock_service: lock_svc.clone(), + discovery_service: discovery_svc.clone(), + app_state: app_state.clone(), + public_base_url: config.base_url(), + wopi_base_url, + }; + + let (protocol, api) = wopi_handler::wopi_routes(wopi_state); + Some((protocol, api)) + } else { + None + } + } else { + None + }; + // Apply auth middleware to protected API routes when auth is enabled if config.features.enable_auth && app_state.auth_service.is_some() { use interfaces::api::handlers::auth_handler::auth_routes; @@ -139,6 +174,17 @@ async fn main() -> Result<(), Box> { .merge(webdav_protected) .merge(web_routes) .layer(TraceLayer::new_for_http()); + + // Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware) + if let Some((wopi_protocol, wopi_api)) = wopi_routes { + let wopi_api_protected = wopi_api.layer(axum::middleware::from_fn_with_state( + Arc::new(app_state.clone()), + auth_middleware, + )); + app = app + .nest("/wopi", wopi_protocol) + .nest("/api/wopi", wopi_api_protected); + } } else { // Auth disabled — no middleware applied tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible"); @@ -151,6 +197,11 @@ async fn main() -> Result<(), Box> { .merge(webdav_router) .merge(web_routes) .layer(TraceLayer::new_for_http()); + + // Mount WOPI routes (no auth middleware when auth is disabled) + if let Some((wopi_protocol, wopi_api)) = wopi_routes { + app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api); + } } // Apply the redirect middleware for legacy routes diff --git a/static/index.html b/static/index.html index 2457b0ab..c443188d 100644 --- a/static/index.html +++ b/static/index.html @@ -34,6 +34,7 @@ + diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 7dc48e51..859fbc1d 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -48,6 +48,12 @@ const ui = {
View
+ +
Download
@@ -721,6 +727,11 @@ const ui = { if (window.recent) { document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } })); } + // WOPI editor intercept: open Office documents in the WOPI editor + if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) { + window.wopiEditor.openInModal(file.id, file.name, 'edit'); + return; + } if (self.isViewableFile(file)) { if (window.inlineViewer) window.inlineViewer.openFile(file); else window.fileOps.downloadFile(file.id, file.name); @@ -838,6 +849,9 @@ const ui = { if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') { window.contextMenus.syncFavoriteOptionLabels(); } + if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') { + window.contextMenus.syncWopiOptionVisibility().catch(function(){}); + } menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; menu.style.display = 'block'; @@ -1268,6 +1282,9 @@ function showContextMenuAtElement(triggerElement, menuId) { if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') { window.contextMenus.syncFavoriteOptionLabels(); } + if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') { + window.contextMenus.syncWopiOptionVisibility().catch(function(){}); + } menu.style.left = `${left}px`; menu.style.top = `${top}px`; diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index 0f62bfe7..b37dc8cb 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -15,6 +15,23 @@ const contextMenus = { : (isFavorite ? 'Remove from favorites' : 'Add to favorites'); }, + /** + * Show or hide WOPI editor options based on current target file + */ + async syncWopiOptionVisibility() { + const wopiEdit = document.getElementById('wopi-edit-file-option'); + const wopiEditTab = document.getElementById('wopi-edit-file-tab-option'); + if (!wopiEdit || !wopiEditTab) return; + + const targetFile = window.app && window.app.contextMenuTargetFile; + const show = targetFile && + window.wopiEditor && + await window.wopiEditor.canEdit(targetFile.name); + + wopiEdit.style.display = show ? '' : 'none'; + wopiEditTab.style.display = show ? '' : 'none'; + }, + syncFavoriteOptionLabels() { if (!window.favorites) return; @@ -137,7 +154,23 @@ const contextMenus = { } window.ui.closeFileContextMenu(); }); - + + document.getElementById('wopi-edit-file-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFile) { + const file = window.app.contextMenuTargetFile; + window.wopiEditor.openInModal(file.id, file.name, 'edit'); + } + window.ui.closeFileContextMenu(); + }); + + document.getElementById('wopi-edit-file-tab-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFile) { + const file = window.app.contextMenuTargetFile; + window.wopiEditor.openInTab(file.id, file.name, 'edit'); + } + window.ui.closeFileContextMenu(); + }); + document.getElementById('download-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { window.fileOps.downloadFile( diff --git a/static/js/features/files/inlineViewer.js b/static/js/features/files/inlineViewer.js index 6ca10638..b732ea79 100644 --- a/static/js/features/files/inlineViewer.js +++ b/static/js/features/files/inlineViewer.js @@ -90,6 +90,13 @@ class InlineViewer { openFile(file) { console.log('Opening file:', file); + + // WOPI editor intercept: open Office documents in the WOPI editor + if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) { + window.wopiEditor.openInModal(file.id, file.name, 'edit'); + return; + } + this.currentFile = file; // Get container diff --git a/static/js/features/files/wopiEditor.js b/static/js/features/files/wopiEditor.js new file mode 100644 index 00000000..035a8b62 --- /dev/null +++ b/static/js/features/files/wopiEditor.js @@ -0,0 +1,238 @@ +/** + * OxiCloud WOPI Editor Integration + * + * Opens document files in Collabora Online / OnlyOffice via WOPI protocol. + * Supports two modes: in-app modal (default) and new browser tab. + */ +class WopiEditor { + constructor() { + this.editorModal = null; + this._escHandler = null; + this._messageHandler = null; + this._supportedExtensions = null; + } + + /** + * Check if a file can be opened in a WOPI editor by extension. + * Fetches supported extensions from the server (cached after first call). + */ + async canEdit(filename) { + var ext = filename.split('.').pop().toLowerCase(); + var supported = await this._getSupportedExtensions(); + return supported.includes(ext); + } + + /** + * Open file in a modal overlay (default mode). + */ + async openInModal(fileId, fileName, action) { + action = action || 'edit'; + try { + var data = await this._getEditorUrl(fileId, action); + this._showModal(data, fileName); + } catch (error) { + console.error('Failed to open WOPI editor:', error); + if (window.showNotification) { + window.showNotification('Could not open the document editor.', 'error'); + } + } + } + + /** + * Open file in a new browser tab. + */ + async openInTab(fileId, fileName, action) { + action = action || 'edit'; + try { + var data = await this._getEditorUrl(fileId, action); + var hostUrl = '/wopi/edit/' + encodeURIComponent(fileId) + + '?access_token=' + encodeURIComponent(data.access_token); + window.open(hostUrl, '_blank'); + } catch (error) { + console.error('Failed to open WOPI editor in tab:', error); + if (window.showNotification) { + window.showNotification('Could not open the document editor.', 'error'); + } + } + } + + /** + * Fetch editor URL and WOPI token from the backend. + */ + async _getEditorUrl(fileId, action) { + var token = localStorage.getItem('oxicloud_token') || ''; + var response = await fetch( + '/api/wopi/editor-url?file_id=' + encodeURIComponent(fileId) + '&action=' + encodeURIComponent(action), + { + headers: { 'Authorization': 'Bearer ' + token } + } + ); + if (!response.ok) { + var text = await response.text(); + throw new Error('Editor URL request failed: ' + response.status + ' ' + text); + } + return response.json(); + } + + /** + * Show the editor in a full-screen modal with iframe. + */ + _showModal(editorData, fileName) { + this.closeEditor(); + + var modal = document.createElement('div'); + modal.id = 'wopi-editor-modal'; + modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:10000;background:#fff;'; + + var header = document.createElement('div'); + header.style.cssText = 'height:40px;background:#333;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-family:sans-serif;font-size:14px;'; + + var title = document.createElement('span'); + title.textContent = fileName; + header.appendChild(title); + + var closeBtn = document.createElement('button'); + closeBtn.textContent = '\u2715'; + closeBtn.style.cssText = 'background:none;border:none;color:#fff;cursor:pointer;font-size:18px;padding:4px 8px;'; + closeBtn.onclick = this.closeEditor.bind(this); + header.appendChild(closeBtn); + + var form = document.createElement('form'); + form.id = 'wopi_form'; + form.target = 'wopi_frame'; + form.action = editorData.editor_url; + form.method = 'post'; + form.style.display = 'none'; + + var tokenInput = document.createElement('input'); + tokenInput.name = 'access_token'; + tokenInput.value = editorData.access_token; + tokenInput.type = 'hidden'; + form.appendChild(tokenInput); + + var ttlInput = document.createElement('input'); + ttlInput.name = 'access_token_ttl'; + ttlInput.value = editorData.access_token_ttl; + ttlInput.type = 'hidden'; + form.appendChild(ttlInput); + + var frameHolder = document.createElement('div'); + frameHolder.style.cssText = 'position:absolute;top:40px;left:0;right:0;bottom:0;'; + + // Loading spinner (removed once the editor signals ready) + var spinner = document.createElement('div'); + spinner.id = 'wopi-loading-spinner'; + spinner.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background:#f5f5f5;z-index:1;'; + spinner.innerHTML = ''; + frameHolder.appendChild(spinner); + + var iframe = document.createElement('iframe'); + iframe.name = 'wopi_frame'; + iframe.title = 'Document Editor'; + iframe.style.cssText = 'width:100%;height:100%;border:none;'; + iframe.setAttribute('allowfullscreen', 'true'); + // Fix 9: allow clipboard access for copy/paste inside the editor + iframe.setAttribute('allow', 'clipboard-read; clipboard-write'); + iframe.setAttribute('sandbox', + 'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox'); + frameHolder.appendChild(iframe); + + modal.appendChild(header); + modal.appendChild(form); + modal.appendChild(frameHolder); + document.body.appendChild(modal); + + // ESC key handler + this._escHandler = function(e) { + if (e.key === 'Escape') this.closeEditor(); + }.bind(this); + document.addEventListener('keydown', this._escHandler); + + // Fix 7: Listen for postMessage from the editor iframe + this._messageHandler = function(e) { + var data; + try { + data = JSON.parse(e.data); + } catch (_) { + return; // Not a JSON message — ignore + } + var msgId = data.MessageId || data.messageId || ''; + if (msgId === 'UI_Close' || msgId === 'close') { + this.closeEditor(); + } else if (msgId === 'App_LoadingStatus') { + var status = data.Values && data.Values.Status; + if (status === 'Document_Loaded' || status === 'Frame_Ready') { + var sp = document.getElementById('wopi-loading-spinner'); + if (sp) sp.remove(); + } + } + }.bind(this); + window.addEventListener('message', this._messageHandler); + + form.submit(); + this.editorModal = modal; + } + + /** + * Close the editor modal and refresh the file list. + */ + closeEditor() { + var modal = document.getElementById('wopi-editor-modal'); + if (modal) modal.remove(); + if (this._escHandler) { + document.removeEventListener('keydown', this._escHandler); + this._escHandler = null; + } + if (this._messageHandler) { + window.removeEventListener('message', this._messageHandler); + this._messageHandler = null; + } + this.editorModal = null; + // Refresh file list to pick up any saves + if (typeof loadFiles === 'function') { + loadFiles(); + } + } + + /** + * Fetch supported extensions from the server (cached). + */ + async _getSupportedExtensions() { + if (this._supportedExtensions !== null) { + return this._supportedExtensions; + } + return this._fetchSupportedExtensions(); + } + + /** + * Fetch supported extensions from /wopi/supported-extensions. + * Falls back to a hardcoded list on failure. + */ + async _fetchSupportedExtensions() { + try { + var response = await fetch('/wopi/supported-extensions'); + if (response.ok) { + var exts = await response.json(); + if (Array.isArray(exts) && exts.length > 0) { + this._supportedExtensions = exts; + return exts; + } + } + } catch (_) { + // Ignore — fall through to hardcoded list + } + // Fallback hardcoded list + this._supportedExtensions = [ + 'docx', 'doc', 'odt', 'rtf', 'txt', + 'xlsx', 'xls', 'ods', 'csv', + 'pptx', 'ppt', 'odp', + ]; + return this._supportedExtensions; + } +} + +// Global instance +window.wopiEditor = new WopiEditor(); + +// Prefetch supported extensions so canEdit() is fast on first use +window.wopiEditor._fetchSupportedExtensions();