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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user