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:
+60
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user