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
+31
View File
@@ -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"
+6
View File
@@ -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<String>,
}
impl From<File> for FileDto {
@@ -65,6 +69,7 @@ impl From<File> 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,
}
}
}
+2
View File
@@ -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;
@@ -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<RwLock<HashMap<String, LockEntry>>>,
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<String> {
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<Self>) {
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");
}
}
@@ -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<WopiTokenClaims, DomainError> {
let validation = Validation::new(Algorithm::HS256);
let token_data = decode::<WopiTokenClaims>(
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);
}
}
+60 -1
View File
@@ -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::<bool>().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::<i64>()
{
config.wopi.token_ttl_secs = val;
}
if let Ok(v) = env::var("OXICLOUD_WOPI_LOCK_TTL_SECS")
&& let Ok(val) = v.parse::<u64>()
{
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
}
+55
View File
@@ -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<Arc<dyn crate::application::ports::carddav_ports::AddressBookUseCase>>,
pub contact_use_case: Option<Arc<dyn crate::application::ports::carddav_ports::ContactUseCase>>,
pub wopi_token_service:
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
pub wopi_lock_service:
Option<Arc<crate::application::services::wopi_lock_service::WopiLockService>>,
pub wopi_discovery_service:
Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>,
}
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,
}
}
+16
View File
@@ -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<String>,
}
// 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<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<String>,
) -> FileResult<Self> {
// 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(),
}
}
}
@@ -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"));
}
}
+1
View File
@@ -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<T> = Result<T, (axum::http::StatusCode, String)>;
+526
View File
@@ -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<WopiTokenService>,
pub lock_service: Arc<WopiLockService>,
pub discovery_service: Arc<WopiDiscoveryService>,
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<String>,
Query(token_query): Query<WopiTokenQuery>,
State(state): State<WopiState>,
) -> 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<String>,
Query(token_query): Query<WopiTokenQuery>,
State(state): State<WopiState>,
) -> 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<String>,
Query(token_query): Query<WopiTokenQuery>,
headers: HeaderMap,
State(state): State<WopiState>,
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<String>,
Query(token_query): Query<WopiTokenQuery>,
headers: HeaderMap,
State(state): State<WopiState>,
) -> 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<EditorUrlParams>,
State(state): State<WopiState>,
) -> Response {
// Get file info to determine extension
let file = match state
.app_state
.applications
.file_retrieval_service
.get_file(&params.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, &params.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(&params.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<String>,
Query(token_query): Query<WopiTokenQuery>,
State(state): State<WopiState>,
) -> 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;");
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{safe_name} - OxiCloud Editor</title>
<style>
body {{ margin: 0; overflow: hidden; }}
iframe {{ width: 100%; height: 100vh; border: none; }}
</style>
</head>
<body>
<form id="wopi_form" action="{editor_url}" method="post" target="wopi_frame">
<input name="access_token" value="{token}" type="hidden"/>
<input name="access_token_ttl" value="{ttl}" type="hidden"/>
</form>
<iframe name="wopi_frame" allowfullscreen
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox">
</iframe>
<script>document.getElementById('wopi_form').submit();</script>
</body>
</html>"#
);
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<WopiState>) -> 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::<String>::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<crate::common::di::AppState>,
Router<crate::common::di::AppState>,
) {
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)
}
+51
View File
@@ -40,6 +40,9 @@ use interfaces::{create_api_routes, create_public_api_routes, web::create_web_ro
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
.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
+1
View File
@@ -34,6 +34,7 @@
<script defer src="/js/features/sharing/fileSharing.js"></script>
<script defer src="/js/views/shared/sharedView.js"></script>
<script defer src="/js/features/files/inlineViewer.js"></script>
<script defer src="/js/features/files/wopiEditor.js"></script>
<script defer src="/js/core/icons.js"></script>
<script defer src="/js/app/navigation.js"></script>
<script defer src="/js/app/authSession.js"></script>
+17
View File
@@ -48,6 +48,12 @@ const ui = {
<div class="context-menu-item" id="view-file-option">
<i class="fas fa-eye"></i> <span data-i18n="actions.view">View</span>
</div>
<div class="context-menu-item" id="wopi-edit-file-option" style="display:none">
<i class="fas fa-file-word"></i> <span>Edit in Office</span>
</div>
<div class="context-menu-item" id="wopi-edit-file-tab-option" style="display:none">
<i class="fas fa-external-link-alt"></i> <span>Edit in Office (new tab)</span>
</div>
<div class="context-menu-item" id="download-file-option">
<i class="fas fa-download"></i> <span data-i18n="actions.download">Download</span>
</div>
@@ -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`;
+33
View File
@@ -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;
@@ -138,6 +155,22 @@ 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(
+7
View File
@@ -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
+238
View File
@@ -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 = '<i class="fas fa-spinner fa-spin" style="font-size:48px;color:#666;"></i>';
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();