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