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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jan Wiebe
2026-02-21 13:39:27 +01:00
parent 807370e194
commit 4e2c9d2592
20 changed files with 1877 additions and 14 deletions
@@ -61,6 +61,7 @@ impl FileBlobReadRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
owner_id: Option<String>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps(
@@ -72,6 +73,7 @@ impl FileBlobReadRepository {
folder_id,
created_at as u64,
modified_at as u64,
owner_id,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
@@ -110,6 +112,7 @@ impl FileReadPort for FileBlobReadRepository {
i64, // created_at
i64, // updated_at
String, // blob_hash
Option<String>, // user_id (owner)
),
>(
r#"
@@ -117,7 +120,8 @@ impl FileReadPort for FileBlobReadRepository {
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash
fi.blob_hash,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.id = $1::uuid AND NOT fi.is_trashed
@@ -136,7 +140,9 @@ impl FileReadPort for FileBlobReadRepository {
.unwrap()
.insert(id.to_string(), row.8.clone());
Self::row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7)
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.9,
)
}
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
@@ -149,13 +155,15 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
Option<String>,
)> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
@@ -171,7 +179,8 @@ impl FileReadPort for FileBlobReadRepository {
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -184,8 +193,8 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma)
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, uid)
})
.collect()
}
@@ -308,13 +317,15 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
Option<String>,
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed
@@ -336,13 +347,15 @@ impl FileReadPort for FileBlobReadRepository {
String,
i64,
i64,
Option<String>,
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.user_id::text
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed
@@ -357,7 +370,7 @@ impl FileReadPort for FileBlobReadRepository {
match row {
Some(r) => Ok(Some(Self::row_to_file(
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7,
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8,
)?)),
None => Ok(None),
}
@@ -83,6 +83,7 @@ impl FileBlobWriteRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
owner_id: Option<String>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps(
@@ -94,6 +95,7 @@ impl FileBlobWriteRepository {
folder_id,
created_at as u64,
modified_at as u64,
owner_id,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
@@ -190,6 +192,7 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
)
}
@@ -272,6 +275,7 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
)
}
@@ -299,7 +303,17 @@ impl FileWritePort for FileBlobWriteRepository {
.ok_or_else(|| DomainError::not_found("File", file_id))?;
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
Self::row_to_file(
row.0,
row.1,
row.2,
folder_path,
row.3,
row.4,
row.5,
row.6,
None,
)
}
async fn copy_file(
@@ -382,7 +396,17 @@ impl FileWritePort for FileBlobWriteRepository {
);
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
Self::row_to_file(
row.0,
row.1,
row.2,
folder_path,
row.3,
row.4,
row.5,
row.6,
None,
)
}
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
@@ -411,7 +435,17 @@ impl FileWritePort for FileBlobWriteRepository {
.ok_or_else(|| DomainError::not_found("File", file_id))?;
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
Self::row_to_file(
row.0,
row.1,
row.2,
folder_path,
row.3,
row.4,
row.5,
row.6,
None,
)
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
@@ -543,6 +577,7 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
)?;
// The target_path is not meaningful for blob storage (content goes to .blobs/)