feat(nextcloud): add Nextcloud-compatible API layer

Implement a complete Nextcloud client compatibility layer so that
Nextcloud desktop/mobile sync clients can connect to OxiCloud.

Key additions:
- Login Flow v2 (device auth) with OIDC bridge support
- WebDAV handler compatible with Nextcloud clients (PROPFIND, GET,
  PUT, DELETE, MKCOL, MOVE, COPY, HEAD, PROPPATCH)
- OCS API endpoints (user info, capabilities, notifications stubs,
  sharees, unified search)
- Basic Auth middleware with app password verification, account
  lockout integration, and blake3-keyed auth cache
- App password management: create, list, revoke via both native
  API (JWT-authenticated profile page) and Nextcloud OCS endpoints
- Nextcloud file ID mapping (oc:fileid) with persistent DB storage
- Chunked upload support (Nextcloud v2 chunking protocol)
- Trashbin WebDAV interface
- Avatar (SVG placeholder) and preview (redirect) handlers
- User profile page with app password management UI
- URL user validation on all DAV routes (403 on mismatch)
- Database schema for app_passwords and nextcloud_object_ids tables

All services are behind a `nextcloud.enabled` config flag and
cleanly separated under src/interfaces/nextcloud/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zjean
2026-03-04 14:02:15 +01:00
parent ecd1a8148a
commit 54eedf5483
64 changed files with 6761 additions and 126 deletions
+320 -17
View File
@@ -7,9 +7,10 @@ use std::pin::Pin;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::services::trash_service::TrashService;
use crate::common::errors::{DomainError, Result};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::file::File;
use crate::domain::entities::folder::Folder;
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
@@ -17,6 +18,301 @@ use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::domain::services::path_service::StoragePath;
/// Test-only service that mirrors `TrashService` logic but accepts generic repos,
/// allowing mock repositories to be injected in unit tests.
struct TrashServiceForTest<TR, FR, FW, FoR> {
trash_repository: Arc<TR>,
file_read_port: Arc<FR>,
file_write_port: Arc<FW>,
folder_storage_port: Arc<FoR>,
retention_days: u32,
}
impl<TR, FR, FW, FoR> TrashServiceForTest<TR, FR, FW, FoR>
where
TR: TrashRepository,
FR: FileReadPort,
FW: FileWritePort,
FoR: FolderRepository,
{
fn new(
trash_repository: Arc<TR>,
file_read_port: Arc<FR>,
file_write_port: Arc<FW>,
folder_storage_port: Arc<FoR>,
retention_days: u32,
) -> Self {
Self {
trash_repository,
file_read_port,
file_write_port,
folder_storage_port,
retention_days,
}
}
}
impl<TR, FR, FW, FoR> TrashUseCase for TrashServiceForTest<TR, FR, FW, FoR>
where
TR: TrashRepository,
FR: FileReadPort,
FW: FileWritePort,
FoR: FolderRepository,
{
async fn get_trash_items(&self, user_id: &str) -> Result<Vec<TrashedItemDto>> {
let user_uuid = Uuid::parse_str(user_id)
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
let items = self.trash_repository.get_trash_items(&user_uuid).await?;
Ok(items
.into_iter()
.map(|item| {
let days_until_deletion = item.days_until_deletion();
TrashedItemDto {
id: item.id().to_string(),
original_id: item.original_id().to_string(),
item_type: match item.item_type() {
TrashedItemType::File => "file".to_string(),
TrashedItemType::Folder => "folder".to_string(),
},
name: item.name().to_string(),
original_path: item.original_path().to_string(),
trashed_at: item.trashed_at(),
days_until_deletion,
}
})
.collect())
}
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: &str) -> Result<()> {
let item_uuid = Uuid::parse_str(item_id)
.map_err(|e| DomainError::validation_error(format!("Invalid item ID: {}", e)))?;
let user_uuid = Uuid::parse_str(user_id)
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
match item_type {
"file" => {
let file = self.file_read_port.get_file(item_id).await.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"File",
format!("Error retrieving file {}: {}", item_id, e),
)
})?;
let original_path = file.storage_path().to_string();
let trashed_item = TrashedItem::new(
item_uuid,
user_uuid,
TrashedItemType::File,
file.name().to_string(),
original_path,
self.retention_days,
);
self.trash_repository
.add_to_trash(&trashed_item)
.await
.map_err(|e| {
DomainError::internal_error(
"TrashRepository",
format!("Failed to add file to trash: {}", e),
)
})?;
self.file_write_port
.move_to_trash(item_id)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"File",
format!("Error moving file {} to trash: {}", item_id, e),
)
})?;
Ok(())
}
"folder" => {
let folder = self
.folder_storage_port
.get_folder(item_id)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::NotFound,
"Folder",
format!("Error retrieving folder {}: {}", item_id, e),
)
})?;
let original_path = folder.storage_path().to_string();
let trashed_item = TrashedItem::new(
item_uuid,
user_uuid,
TrashedItemType::Folder,
folder.name().to_string(),
original_path,
self.retention_days,
);
self.trash_repository
.add_to_trash(&trashed_item)
.await
.map_err(|e| {
DomainError::internal_error(
"TrashRepository",
format!("Failed to add folder to trash: {}", e),
)
})?;
self.folder_storage_port
.move_to_trash(item_id)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"Folder",
format!("Error moving folder {} to trash: {}", item_id, e),
)
})?;
Ok(())
}
_ => Err(DomainError::validation_error(format!(
"Invalid item type: {}",
item_type
))),
}
}
async fn restore_item(&self, trash_id: &str, user_id: &str) -> Result<()> {
let trash_uuid = Uuid::parse_str(trash_id)
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
let user_uuid = Uuid::parse_str(user_id)
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
let item = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await?;
match item {
Some(item) => {
match item.item_type() {
TrashedItemType::File => {
let file_id = item.original_id().to_string();
let original_path = item.original_path().to_string();
let result = self
.file_write_port
.restore_from_trash(&file_id, &original_path)
.await;
if let Err(e) = result {
if !format!("{}", e).contains("not found") {
return Err(DomainError::new(
ErrorKind::InternalError,
"File",
format!("Error restoring file {} from trash: {}", file_id, e),
));
}
}
}
TrashedItemType::Folder => {
let folder_id = item.original_id().to_string();
let original_path = item.original_path().to_string();
let result = self
.folder_storage_port
.restore_from_trash(&folder_id, &original_path)
.await;
if let Err(e) = result {
if !format!("{}", e).contains("not found") {
return Err(DomainError::new(
ErrorKind::InternalError,
"Folder",
format!(
"Error restoring folder {} from trash: {}",
folder_id, e
),
));
}
}
}
}
self.trash_repository
.restore_from_trash(&trash_uuid, &user_uuid)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"Trash",
format!("Error removing trash entry after restoration: {}", e),
)
})?;
Ok(())
}
None => Ok(()),
}
}
async fn delete_permanently(&self, trash_id: &str, user_id: &str) -> Result<()> {
let trash_uuid = Uuid::parse_str(trash_id)
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
let user_uuid = Uuid::parse_str(user_id)
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
let item = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await?;
match item {
Some(item) => {
match item.item_type() {
TrashedItemType::File => {
let file_id = item.original_id().to_string();
let result = self.file_write_port.delete_file_permanently(&file_id).await;
if let Err(e) = result {
if !format!("{}", e).contains("not found") {
return Err(DomainError::new(
ErrorKind::InternalError,
"File",
format!("Error deleting file {} permanently: {}", file_id, e),
));
}
}
}
TrashedItemType::Folder => {
let folder_id = item.original_id().to_string();
let result = self
.folder_storage_port
.delete_folder_permanently(&folder_id)
.await;
if let Err(e) = result {
if !format!("{}", e).contains("not found") {
return Err(DomainError::new(
ErrorKind::InternalError,
"Folder",
format!(
"Error deleting folder {} permanently: {}",
folder_id, e
),
));
}
}
}
}
self.trash_repository
.delete_permanently(&trash_uuid, &user_uuid)
.await
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"Trash",
format!("Error removing trash entry: {}", e),
)
})?;
Ok(())
}
None => Ok(()),
}
}
async fn empty_trash(&self, user_id: &str) -> Result<()> {
let user_uuid = Uuid::parse_str(user_id)
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
self.trash_repository.clear_trash(&user_uuid).await
}
}
// Mock repositories for testing
struct MockTrashRepository {
trash_items: Mutex<HashMap<Uuid, TrashedItem>>,
@@ -180,6 +476,13 @@ impl FileReadPort for MockFileRepository {
unimplemented!()
}
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
) -> std::result::Result<String, DomainError> {
unimplemented!()
}
async fn get_blob_hash(&self, _file_id: &str) -> std::result::Result<String, DomainError> {
Ok(String::new())
}
@@ -518,10 +821,10 @@ mod tests {
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
let service = TrashService::new(
let service = TrashServiceForTest::new(
trash_repo.clone(),
file_repo.clone() as Arc<FileBlobReadRepository>,
file_repo.clone() as Arc<FileBlobWriteRepository>,
file_repo.clone(),
file_repo.clone(),
folder_repo.clone(),
30, // 30 days retention
);
@@ -592,10 +895,10 @@ mod tests {
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
let service = TrashService::new(
let service = TrashServiceForTest::new(
trash_repo.clone(),
file_repo.clone() as Arc<FileBlobReadRepository>,
file_repo.clone() as Arc<FileBlobWriteRepository>,
file_repo.clone(),
file_repo.clone(),
folder_repo.clone(),
30, // 30 days retention
);
@@ -657,10 +960,10 @@ mod tests {
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
let service = TrashService::new(
let service = TrashServiceForTest::new(
trash_repo.clone(),
file_repo.clone() as Arc<FileBlobReadRepository>,
file_repo.clone() as Arc<FileBlobWriteRepository>,
file_repo.clone(),
file_repo.clone(),
folder_repo.clone(),
30, // 30 days retention
);
@@ -727,10 +1030,10 @@ mod tests {
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
let service = TrashService::new(
let service = TrashServiceForTest::new(
trash_repo.clone(),
file_repo.clone() as Arc<FileBlobReadRepository>,
file_repo.clone() as Arc<FileBlobWriteRepository>,
file_repo.clone(),
file_repo.clone(),
folder_repo.clone(),
30, // 30 days retention
);
@@ -796,10 +1099,10 @@ mod tests {
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
let service = TrashService::new(
let service = TrashServiceForTest::new(
trash_repo.clone(),
file_repo.clone() as Arc<FileBlobReadRepository>,
file_repo.clone() as Arc<FileBlobWriteRepository>,
file_repo.clone(),
file_repo.clone(),
folder_repo.clone(),
30, // 30 days retention
);