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
@@ -120,6 +120,62 @@ pub enum LockType {
Write,
}
/// Extra property context for Nextcloud/ownCloud WebDAV extensions.
#[derive(Debug, Clone)]
pub struct NextcloudPropContext {
pub file_id: Option<i64>,
pub oc_id: Option<String>,
pub owner_id: Option<String>,
pub owner_display_name: Option<String>,
pub permissions: String,
pub size: u64,
pub has_preview: bool,
pub is_encrypted: bool,
pub mount_type: String,
pub contained_file_count: u64,
pub contained_folder_count: u64,
}
impl NextcloudPropContext {
pub fn for_folder(
file_id: Option<i64>,
oc_id: Option<String>,
owner: &str,
contained_files: u64,
contained_folders: u64,
) -> Self {
Self {
file_id,
oc_id,
owner_id: Some(owner.to_string()),
owner_display_name: Some(owner.to_string()),
permissions: "RGDNVCK".to_string(),
size: 0,
has_preview: false,
is_encrypted: false,
mount_type: "dir".to_string(),
contained_file_count: contained_files,
contained_folder_count: contained_folders,
}
}
pub fn for_file(file_id: Option<i64>, oc_id: Option<String>, owner: &str, size: u64) -> Self {
Self {
file_id,
oc_id,
owner_id: Some(owner.to_string()),
owner_display_name: Some(owner.to_string()),
permissions: "RGDNVW".to_string(),
size,
has_preview: false,
is_encrypted: false,
mount_type: "file".to_string(),
contained_file_count: 0,
contained_folder_count: 0,
}
}
}
/// WebDAV adapter for converting between XML and domain objects
pub struct WebDavAdapter;
+9 -10
View File
@@ -1,15 +1,14 @@
// Shared display helpers for DTOs.
//
// These functions centralise the mime→icon / mime→category / size→human-string
// logic so that every API response carries pre-computed display fields and the
// frontend does **not** need to duplicate these mappings.
//
// The approach is: try MIME first (specific matches beat prefix matches),
// then fall back to the file extension when the MIME is generic
// (`application/octet-stream` or empty).
//! Shared display helpers for DTOs.
//!
//! These functions centralise the mime→icon / mime→category / size→human-string
//! logic so that every API response carries pre-computed display fields and the
//! frontend does **not** need to duplicate these mappings.
//!
//! The approach is: try MIME first (specific matches beat prefix matches),
//! then fall back to the file extension when the MIME is generic
//! (`application/octet-stream` or empty).
// ─── Private: extract lowercase extension from a filename ────────────
fn ext_of(name: &str) -> Option<&str> {
let name = name.rsplit('/').next().unwrap_or(name); // strip path
let after_dot = name.rsplit('.').next()?;
+24
View File
@@ -87,6 +87,30 @@ pub struct CurrentUser {
pub role: String,
}
// ============================================================================
// App Password DTOs
// ============================================================================
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateAppPasswordDto {
pub label: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AppPasswordCreatedDto {
pub id: String,
pub label: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AppPasswordDto {
pub id: String,
pub label: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
// ============================================================================
// OIDC DTOs
// ============================================================================
+16 -2
View File
@@ -93,6 +93,9 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Lists users with pagination
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
/// Searches users by username or email (SQL ILIKE) with a limit.
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError>;
/// Lists users by role (e.g., "admin" or "user")
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
@@ -249,8 +252,19 @@ pub trait AppPasswordStoragePort: Send + Sync + 'static {
/// Update the `last_used_at` timestamp after a successful authentication.
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError>;
/// Deactivate (soft-delete) an app password.
async fn revoke(&self, id: &str) -> Result<(), DomainError>;
/// Get active app passwords for a user filtered by token prefix (first 8 chars).
/// More efficient than `get_active_by_user_id` when the password prefix is known.
async fn get_active_by_user_prefix(
&self,
user_id: &str,
prefix: &str,
) -> Result<Vec<AppPassword>, DomainError>;
/// Deactivate (soft-delete) an app password, scoped to the owning user.
async fn revoke(&self, id: &str, user_id: &str) -> Result<(), DomainError>;
/// Delete an app password owned by a specific user. Returns true if found and deleted.
async fn delete_by_user_and_id(&self, id: &str, user_id: &str) -> Result<bool, DomainError>;
/// Hard-delete expired/revoked app passwords (cleanup).
async fn delete_expired(&self) -> Result<u64, DomainError>;
+6
View File
@@ -152,6 +152,12 @@ pub trait DedupPort: Send + Sync + 'static {
/// Calculate BLAKE3 hash of a file (streaming).
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
/// Get the physical filesystem path for a blob by its hash.
///
/// Returns the path where the blob is stored on disk.
/// Used by services that need direct filesystem access (e.g., thumbnail generation).
fn blob_path(&self, hash: &str) -> PathBuf;
/// Get deduplication statistics.
async fn get_stats(&self) -> DedupStatsDto;
+18
View File
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
use crate::common::errors::Result;
@@ -27,6 +29,14 @@ pub trait FavoritesUseCase: Send + Sync {
user_id: &str,
items: &[(String, String)],
) -> Result<BatchFavoritesResult>;
/// Check which of the given item IDs are favorites for this user.
/// Returns the set of item_ids that are favorites.
async fn batch_check_favorites(
&self,
user_id: &str,
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
) -> Result<HashSet<String>>;
}
// ─────────────────────────────────────────────────────
@@ -54,4 +64,12 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
/// Insert multiple items in a single transaction.
/// Returns the number of rows actually inserted (ignoring duplicates).
async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result<u64>;
/// Check which of the given item IDs are favorites for this user.
/// Returns the set of item_ids that are favorites.
async fn batch_check_favorites(
&self,
user_id: &str,
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
) -> Result<HashSet<String>>;
}
+3
View File
@@ -82,6 +82,9 @@ pub trait FileReadPort: Send + Sync + 'static {
/// Gets the parent folder ID from a path (WebDAV).
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
/// Gets a folder ID by its path.
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError>;
/// Gets the content-addressable blob hash for a file (O(1) DB lookup).
///
/// Returns the BLAKE3 hash stored in `storage.files.blob_hash`.
+237 -24
View File
@@ -7,13 +7,14 @@ use crate::application::dtos::app_password_dto::*;
use crate::application::ports::auth_ports::{
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
};
use crate::common::errors::DomainError;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::app_password::AppPassword;
use crate::infrastructure::repositories::pg::AppPasswordPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use chrono::{Duration, Utc};
use moka::future::Cache;
use rand_core::RngCore;
use std::sync::Arc;
use std::time::Duration as StdDuration;
@@ -22,6 +23,11 @@ const TOKEN_LENGTH: usize = 32;
/// Prefix for all app password tokens (makes them easily identifiable).
const TOKEN_PREFIX: &str = "oxicloud-";
// ── Nextcloud-format app password constants ──
const NC_APP_PASSWORD_GROUPS: usize = 5;
const NC_APP_PASSWORD_GROUP_LEN: usize = 5;
const NC_PREFIX_LEN: usize = 8;
/// TTL for cached Basic Auth verification results.
/// Balances performance (avoids repeated Argon2id + DB queries) with security
/// (limits the window during which a revoked app password remains usable).
@@ -231,13 +237,16 @@ impl AppPasswordService {
user_id: &str,
id: &str,
) -> Result<AppPasswordRevokeResponseDto, DomainError> {
// Ownership enforced at SQL level (WHERE user_id = $2).
// The get_by_id pre-check gives a clear error message when
// the password doesn't belong to the caller.
let ap = self.repo.get_by_id(id).await?;
if ap.user_id != user_id {
return Err(DomainError::unauthorized(
"You can only revoke your own app passwords",
));
}
self.repo.revoke(id).await?;
self.repo.revoke(id, user_id).await?;
// Invalidate all cached auth entries for this user so the
// revocation is effective immediately.
@@ -262,23 +271,19 @@ impl AppPasswordService {
///
/// Returns `(user_id, username, email, role)` on success.
///
/// ## Performance
/// Handles both `oxicloud-` format and Nextcloud format (`XXXXX-XXXXX-...`)
/// passwords. Uses prefix-based DB lookup to minimize Argon2id attempts.
///
/// Successful verifications are cached for `BASIC_AUTH_CACHE_TTL_SECS`
/// (default 30 s) keyed by `blake3(username:password)`. This avoids
/// the expensive Argon2id computation **and** the three PostgreSQL
/// round-trips on every repeated DAV request from the same client.
///
/// Failed verifications are **never** cached, preserving the full
/// Argon2id cost as a brute-force deterrent.
/// keyed by `blake3(username:password)`. Failed verifications are
/// **never** cached, preserving the full Argon2id cost as a brute-force
/// deterrent.
pub async fn verify_basic_auth(
&self,
username: &str,
password: &str,
) -> Result<(String, String, String, String), DomainError> {
// ── 1. Compute cache key = blake3("username:password") ────────
// The plain-text password is never stored; only the 32-byte
// cryptographic digest is used as lookup key.
let cache_key: [u8; 32] =
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
@@ -288,30 +293,57 @@ impl AppPasswordService {
}
// ── 3. Cache miss → full verification ────────────────────────
// Look up user by username
let user = self
.user_repo
.get_user_by_username(username)
.await
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
// Get all active app passwords for this user
let app_passwords = self.repo.get_active_by_user_id(user.id()).await?;
if app_passwords.is_empty() {
if !user.is_active() {
return Err(DomainError::unauthorized(
"Invalid username or app password",
));
}
// Try each app password hash (Argon2id — CPU-intensive)
for ap in &app_passwords {
// Determine the password form and prefix for DB lookup.
// oxicloud- format: use raw password, prefix = first 17 chars
// NC format: normalize (strip dashes/whitespace, uppercase), prefix = first 8 chars
let (verify_password, prefix) = if password.starts_with(TOKEN_PREFIX) {
let pfx = password
.get(..TOKEN_PREFIX.len() + 8)
.unwrap_or(password)
.to_string();
(password.to_string(), pfx)
} else {
let norm = nc_normalize_password(password);
match nc_token_prefix(&norm) {
Ok(pfx) => (norm, pfx),
Err(_) => {
return Err(DomainError::unauthorized(
"Invalid username or app password",
));
}
}
};
// Use prefix-based lookup for efficiency (fewer Argon2id attempts)
let candidates = self
.repo
.get_active_by_user_prefix(user.id(), &prefix)
.await?;
if candidates.is_empty() {
return Err(DomainError::unauthorized(
"Invalid username or app password",
));
}
for ap in &candidates {
if let Ok(true) = self
.hasher
.verify_password(password, &ap.password_hash)
.verify_password(&verify_password, &ap.password_hash)
.await
{
// Update last_used_at (fire-and-forget; don't fail auth on touch error)
let _ = self.repo.touch_last_used(&ap.id).await;
let result = CachedBasicAuthResult {
@@ -321,17 +353,198 @@ impl AppPasswordService {
role: user.role().to_string(),
};
// ── 4. Cache the successful result ────────────────────
self.auth_cache.insert(cache_key, result.clone()).await;
return Ok((result.user_id, result.username, result.email, result.role));
}
}
// Failed verifications are intentionally NOT cached so that
// brute-force attackers always pay the full Argon2id cost.
Err(DomainError::unauthorized(
"Invalid username or app password",
))
}
// ========================================================================
// Nextcloud-format app password methods
// ========================================================================
/// Create a Nextcloud-format app password (`XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`).
///
/// Returns `(id, plain_password)`.
pub async fn create_nc(
&self,
user_id: &str,
label: &str,
) -> Result<(String, String), DomainError> {
let password = generate_nc_app_password();
let normalized = nc_normalize_password(&password);
let prefix = nc_token_prefix(&normalized)?;
let hash = self.hasher.hash_password(&normalized).await?;
let ap = AppPassword::new(
user_id.to_string(),
label.to_string(),
hash,
prefix,
"all".to_string(),
None,
);
let saved = self.repo.create(ap).await?;
Ok((saved.id, password))
}
/// Revoke an app password by matching the raw password value.
/// Scoped to the authenticated user (fixes I3 — no global prefix search).
pub async fn revoke_by_password(
&self,
user_id: &str,
password: &str,
) -> Result<(), DomainError> {
let normalized = nc_normalize_password(password);
let prefix = match nc_token_prefix(&normalized) {
Ok(pfx) => pfx,
Err(_) => return Ok(()),
};
let candidates = self
.repo
.get_active_by_user_prefix(user_id, &prefix)
.await?;
for ap in candidates {
if let Ok(true) = self
.hasher
.verify_password(&normalized, &ap.password_hash)
.await
{
self.repo.revoke(&ap.id, user_id).await?;
// Invalidate cache for this user
let uid = user_id.to_string();
self.auth_cache
.invalidate_entries_if(move |_key, val| val.user_id == uid)
.ok();
break;
}
}
Ok(())
}
/// List app passwords for a user (simple summary for NC UI).
pub async fn list_nc(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
self.repo.list_by_user(user_id).await
}
/// Delete an app password by ID, scoped to the owning user.
pub async fn delete_by_user(&self, id: &str, user_id: &str) -> Result<(), DomainError> {
let deleted = self.repo.delete_by_user_and_id(id, user_id).await?;
if !deleted {
return Err(DomainError::new(
ErrorKind::NotFound,
"AppPassword",
"App password not found",
));
}
Ok(())
}
}
// ============================================================================
// Nextcloud app password helpers (module-private)
// ============================================================================
/// Generate a Nextcloud-format app password: `XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`
/// using rejection sampling to avoid modulo bias.
fn generate_nc_app_password() -> String {
let mut rng = rand_core::OsRng;
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let len = chars.len() as u32; // 36
let mut groups = Vec::with_capacity(NC_APP_PASSWORD_GROUPS);
for _ in 0..NC_APP_PASSWORD_GROUPS {
let mut group = String::with_capacity(NC_APP_PASSWORD_GROUP_LEN);
for _ in 0..NC_APP_PASSWORD_GROUP_LEN {
let threshold = u32::MAX - (u32::MAX % len);
let idx = loop {
let val = rng.next_u32();
if val < threshold {
break (val % len) as usize;
}
};
group.push(chars[idx] as char);
}
groups.push(group);
}
groups.join("-")
}
/// Normalize a Nextcloud-format password: strip dashes/whitespace, uppercase.
fn nc_normalize_password(password: &str) -> String {
password
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.map(|c| c.to_ascii_uppercase())
.collect()
}
/// Extract the first 8 characters as the token prefix for DB lookup.
fn nc_token_prefix(normalized: &str) -> Result<String, DomainError> {
if normalized.len() < NC_PREFIX_LEN {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"AppPassword",
"App password too short",
));
}
Ok(normalized[..NC_PREFIX_LEN].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_nc_app_password_format() {
let password = generate_nc_app_password();
let groups: Vec<&str> = password.split('-').collect();
assert_eq!(groups.len(), NC_APP_PASSWORD_GROUPS);
for group in &groups {
assert_eq!(group.len(), NC_APP_PASSWORD_GROUP_LEN);
assert!(group.chars().all(|c| c.is_ascii_alphanumeric()));
}
}
#[test]
fn test_nc_normalize_password_strips_dashes_and_whitespace() {
assert_eq!(
nc_normalize_password("AB12C-DE34F-GH56I"),
"AB12CDE34FGH56I"
);
}
#[test]
fn test_nc_normalize_password_uppercases() {
assert_eq!(nc_normalize_password("abc-def"), "ABCDEF");
}
#[test]
fn test_nc_token_prefix_extracts_first_8_chars() {
assert_eq!(nc_token_prefix("ABCDEFGHIJKLMNOP").unwrap(), "ABCDEFGH");
}
#[test]
fn test_nc_token_prefix_too_short() {
assert!(nc_token_prefix("SHORT").is_err());
}
#[test]
fn test_generated_nc_password_produces_valid_prefix() {
let password = generate_nc_app_password();
let normalized = nc_normalize_password(&password);
let prefix = nc_token_prefix(&normalized);
assert!(prefix.is_ok());
assert_eq!(prefix.unwrap().len(), NC_PREFIX_LEN);
}
}
@@ -22,11 +22,31 @@ use std::sync::Arc;
use std::sync::RwLock;
use std::time::Duration;
/// Result of a successful OIDC callback. The handler layer inspects this to
/// decide whether to redirect to the regular frontend or complete a Nextcloud
/// Login Flow v2 session.
pub enum OidcCallbackResult {
/// Regular web login — contains a one-time exchange code for the frontend.
WebLogin { exchange_code: String },
/// Nextcloud Login Flow v2 — the user authenticated via OIDC but the flow
/// was initiated from the Nextcloud login page. The handler must create an
/// app password and complete the NC login flow.
NextcloudLogin {
nc_flow_token: String,
user_id: String,
username: String,
},
}
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
#[derive(Clone)]
struct PendingOidcFlow {
pkce_verifier: String,
nonce: String,
/// When set, this OIDC flow was initiated from the Nextcloud Login Flow v2
/// page. On successful callback the flow will mint an app-password and
/// complete the Nextcloud login flow instead of issuing internal JWTs.
nc_flow_token: Option<String>,
}
/// Tracks a pending one-time token exchange after successful OIDC callback
@@ -410,6 +430,49 @@ impl AuthApplicationService {
})
}
/// Verifies username/password credentials without creating a session.
pub async fn verify_credentials(
&self,
username: &str,
password: &str,
) -> Result<crate::application::dtos::user_dto::CurrentUser, DomainError> {
let user = self
.user_storage
.get_user_by_username(username)
.await
.map_err(|_| {
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
})?;
if !user.is_active() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Account deactivated",
));
}
let is_valid = self
.password_hasher
.verify_password(password, user.password_hash())
.await?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Invalid credentials",
));
}
Ok(crate::application::dtos::user_dto::CurrentUser {
id: user.id().to_string(),
username: user.username().to_string(),
email: user.email().to_string(),
role: user.role().to_string(),
})
}
pub async fn refresh_token(
&self,
dto: RefreshTokenDto,
@@ -605,6 +668,11 @@ impl AuthApplicationService {
Ok(users.into_iter().map(UserDto::from).collect())
}
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
let users = self.user_storage.search_users(query, limit).await?;
Ok(users.into_iter().map(UserDto::from).collect())
}
// ========================================================================
// Admin User Management Methods
// ========================================================================
@@ -856,6 +924,7 @@ impl AuthApplicationService {
PendingOidcFlow {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: None,
},
);
@@ -872,11 +941,77 @@ impl AuthApplicationService {
Ok(authorize_url)
}
/// Prepare an OIDC authorization flow for a Nextcloud Login Flow v2 session.
///
/// Works like [`prepare_oidc_authorize`] but associates the Nextcloud flow
/// token with the OIDC state so that [`oidc_callback`] can complete the
/// Nextcloud login flow (app-password + poll result) instead of issuing
/// internal JWTs.
pub async fn prepare_oidc_authorize_for_nextcloud(
&self,
nc_flow_token: &str,
) -> Result<String, DomainError> {
let oidc = self.oidc_service().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC service not configured",
)
})?;
use rand_core::{OsRng, RngCore};
let mut state_bytes = [0u8; 32];
OsRng.fill_bytes(&mut state_bytes);
let state_token = hex::encode(state_bytes);
let mut nonce_bytes = [0u8; 32];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(nonce_bytes);
let mut verifier_bytes = [0u8; 32];
OsRng.fill_bytes(&mut verifier_bytes);
let pkce_verifier = base64_url_encode(&verifier_bytes);
let pkce_challenge = {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(pkce_verifier.as_bytes());
base64_url_encode(&hash)
};
// Store pending flow (auto-expires after 10 min via moka TTL)
self.pending_oidc_flows.insert(
state_token.clone(),
PendingOidcFlow {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: Some(nc_flow_token.to_string()),
},
);
let authorize_url = oidc
.get_authorize_url(&state_token, &nonce, &pkce_challenge)
.await?;
tracing::info!(
"OIDC authorize flow prepared for Nextcloud Login Flow v2 (state={}...)",
&state_token[..8]
);
Ok(authorize_url)
}
/// Handle the OIDC callback: validate CSRF state, exchange code with PKCE,
/// validate ID token nonce, find or create user (JIT provisioning),
/// issue internal tokens, and return a one-time exchange code.
pub async fn oidc_callback(&self, code: &str, state: &str) -> Result<String, DomainError> {
// 0. Validate CSRF state and retrieve PKCE verifier + nonce
///
/// If the pending flow carries a Nextcloud flow token, this method returns
/// `Err(NcOidcComplete { .. })` with a special error kind so the handler
/// layer can complete the Nextcloud flow instead.
pub async fn oidc_callback(
&self,
code: &str,
state: &str,
) -> Result<OidcCallbackResult, DomainError> {
// 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token
// (entry is auto-expired by moka TTL — remove returns None if expired)
let flow = self.pending_oidc_flows.remove(state).ok_or_else(|| {
tracing::warn!("OIDC callback with invalid/expired state token");
@@ -885,7 +1020,8 @@ impl AuthApplicationService {
"Invalid or expired OIDC state — possible CSRF attack. Please try logging in again.",
)
})?;
let (pkce_verifier, nonce) = (flow.pkce_verifier, flow.nonce);
let (pkce_verifier, nonce, nc_flow_token) =
(flow.pkce_verifier, flow.nonce, flow.nc_flow_token);
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
let (oidc, oidc_config) = {
@@ -1063,6 +1199,21 @@ impl AuthApplicationService {
}
};
// ── Branch: Nextcloud Login Flow v2 vs regular web login ──
if let Some(nc_token) = nc_flow_token {
// Nextcloud path: return user info so the handler can mint an
// app-password and complete the NC login flow.
tracing::info!(
user = %user.username(),
"OIDC login successful for Nextcloud Login Flow v2"
);
return Ok(OidcCallbackResult::NextcloudLogin {
nc_flow_token: nc_token,
user_id: user.id().to_string(),
username: user.username().to_string(),
});
}
// 6. Issue internal tokens (same as regular login)
let access_token = self.token_service.generate_access_token(&user)?;
let refresh_token = self.token_service.generate_refresh_token();
@@ -1096,7 +1247,7 @@ impl AuthApplicationService {
tracing::info!("OIDC login successful, one-time exchange code generated");
Ok(exchange_code)
Ok(OidcCallbackResult::WebLogin { exchange_code })
}
/// Exchange a one-time code for the authentication tokens.
+11 -7
View File
@@ -1042,18 +1042,22 @@ impl BatchOperationService {
#[cfg(integration_tests)]
mod tests {
use super::*;
use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use std::sync::Arc;
#[tokio::test]
async fn test_generic_batch_operation() {
// Create the batch service with stubs
// Create the batch service with stub repositories (lazy pool — no SQL is executed
// in this test; generic_batch_operation never touches file/folder services).
let folder_repo = Arc::new(FolderDbRepository::new_stub());
let file_read_repo = Arc::new(FileBlobReadRepository::new_stub());
let file_write_repo = Arc::new(FileBlobWriteRepository::new_stub());
let batch_service = BatchOperationService::new(
Arc::new(StubFileRetrievalUseCase),
Arc::new(StubFileManagementUseCase),
Arc::new(FolderService::new(Arc::new(
crate::common::stubs::StubFolderStoragePort,
))),
Arc::new(FileRetrievalService::new(file_read_repo)),
Arc::new(FileManagementService::new(file_write_repo)),
Arc::new(FolderService::new(folder_repo)),
AppConfig::default(),
);
@@ -262,6 +262,10 @@ impl DeviceAuthService {
let refresh_token = dc.refresh_token().unwrap_or_default().to_string();
let scope = dc.scopes().to_string();
// Delete the device code row now that tokens have been retrieved.
// This prevents plain-text tokens from lingering in the database.
let _ = self.device_code_storage.delete_by_id(dc.id()).await;
Ok(DeviceTokenSuccessDto {
access_token,
token_type: "Bearer".to_string(),
+13 -2
View File
@@ -1,11 +1,14 @@
use std::collections::HashSet;
use std::sync::Arc;
use tracing::info;
use crate::application::dtos::favorites_dto::{
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
};
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
use std::sync::Arc;
use tracing::info;
/// Implementation of the FavoritesUseCase for managing user favorites.
///
@@ -142,4 +145,12 @@ impl FavoritesUseCase for FavoritesService {
favorites,
})
}
async fn batch_check_favorites(
&self,
user_id: &str,
item_ids: &[(&str, &str)],
) -> Result<HashSet<String>> {
self.repo.batch_check_favorites(user_id, item_ids).await
}
}
@@ -182,9 +182,11 @@ impl FileUploadUseCase for FileUploadService {
content: &[u8],
content_type: &str,
) -> Result<FileDto, DomainError> {
// Look up the folder ID by folder path
let parent_id = if !parent_path.is_empty() {
if let Some(file_read) = &self.file_read {
file_read.get_parent_folder_id(parent_path).await.ok()
// Use get_folder_id_by_path to look up the folder directly
file_read.get_folder_id_by_path(parent_path).await.ok()
} else {
None
}
+2
View File
@@ -12,6 +12,8 @@ pub mod file_upload_service;
pub mod file_use_case_factory;
pub mod folder_service;
pub mod i18n_application_service;
pub mod nextcloud_file_id_service;
pub mod nextcloud_login_flow_service;
pub mod recent_service;
pub mod search_service;
pub mod share_service;
@@ -0,0 +1,110 @@
use std::sync::Arc;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::infrastructure::repositories::pg::NextcloudObjectIdRepository;
#[derive(Clone)]
pub struct NextcloudFileIdService {
repo: Option<Arc<NextcloudObjectIdRepository>>,
instance_id: String,
}
impl NextcloudFileIdService {
pub fn new(repo: Arc<NextcloudObjectIdRepository>, instance_id: String) -> Self {
Self {
repo: Some(repo),
instance_id,
}
}
pub fn new_stub() -> Self {
Self {
repo: None,
instance_id: "ocnca".to_string(),
}
}
pub async fn get_or_create_file_id(&self, file_id: &str) -> Result<i64> {
let repo = self.repo.as_ref().ok_or_else(|| {
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
})?;
repo.get_or_create("file", file_id).await
}
pub async fn get_or_create_folder_id(&self, folder_id: &str) -> Result<i64> {
let repo = self.repo.as_ref().ok_or_else(|| {
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
})?;
repo.get_or_create("folder", folder_id).await
}
/// Get the OxiCloud file UUID from a Nextcloud numeric ID.
pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result<String> {
let repo = self.repo.as_ref().ok_or_else(|| {
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
})?;
repo.get_object_id(nc_file_id, "file").await
}
pub fn format_oc_id(&self, id: i64) -> String {
format!("{:08}{}", id, self.instance_id)
}
pub fn instance_id(&self) -> &str {
&self.instance_id
}
#[cfg(test)]
pub fn new_test(instance_id: &str) -> Self {
Self {
repo: None,
instance_id: instance_id.to_string(),
}
}
pub fn ensure_ready(&self) -> Result<()> {
if self.repo.is_none() {
return Err(DomainError::new(
ErrorKind::InternalError,
"NextcloudFileId",
"Repository not initialized",
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_oc_id_default_instance() {
let svc = NextcloudFileIdService::new_stub();
assert_eq!(svc.format_oc_id(42), "00000042ocnca");
}
#[test]
fn test_format_oc_id_custom_instance() {
let svc = NextcloudFileIdService::new_test("myinst");
assert_eq!(svc.format_oc_id(1), "00000001myinst");
}
#[test]
fn test_format_oc_id_large_number() {
let svc = NextcloudFileIdService::new_stub();
assert_eq!(svc.format_oc_id(123456789), "123456789ocnca");
}
#[test]
fn test_instance_id() {
let svc = NextcloudFileIdService::new_stub();
assert_eq!(svc.instance_id(), "ocnca");
}
#[test]
fn test_ensure_ready_fails_on_stub() {
let svc = NextcloudFileIdService::new_stub();
assert!(svc.ensure_ready().is_err());
}
}
@@ -0,0 +1,269 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rand_core::RngCore;
/// Maximum number of concurrent pending login flows to prevent memory exhaustion.
const MAX_PENDING_FLOWS: usize = 1000;
#[derive(Debug, Clone)]
pub struct LoginFlowInfo {
pub poll_token: String,
pub poll_endpoint: String,
pub login_url: String,
}
#[derive(Debug)]
pub enum LoginFlowError {
TooManyPendingFlows,
}
#[derive(Debug, Clone)]
pub struct LoginResult {
pub server: String,
pub login_name: String,
pub app_password: String,
}
#[derive(Debug)]
struct PendingFlow {
created_at: Instant,
poll_token: String,
completed: Option<LoginResult>,
}
#[derive(Default)]
struct FlowState {
flows: HashMap<String, PendingFlow>,
poll_to_flow: HashMap<String, String>,
}
#[derive(Clone)]
pub struct NextcloudLoginFlowService {
ttl: Duration,
/// Uses `std::sync::Mutex` (not `tokio::sync::Mutex`) because the lock is
/// never held across an `.await` point — all operations are synchronous
/// HashMap lookups/inserts. This avoids the overhead of an async mutex.
/// **Constraint:** Do not add `.await` calls inside any `self.state.lock()` scope.
state: Arc<Mutex<FlowState>>,
}
impl NextcloudLoginFlowService {
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
state: Arc::new(Mutex::new(FlowState::default())),
}
}
pub fn new_stub() -> Self {
Self::new(Duration::from_secs(600))
}
pub fn initiate(&self, base_url: &str) -> Result<LoginFlowInfo, LoginFlowError> {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
if state.flows.len() >= MAX_PENDING_FLOWS {
return Err(LoginFlowError::TooManyPendingFlows);
}
let poll_token = random_hex(64);
let flow_token = random_hex(48);
state
.poll_to_flow
.insert(poll_token.clone(), flow_token.clone());
state.flows.insert(
flow_token.clone(),
PendingFlow {
created_at: Instant::now(),
poll_token: poll_token.clone(),
completed: None,
},
);
Ok(LoginFlowInfo {
poll_token: poll_token.clone(),
poll_endpoint: format!("{}/login/v2/poll", base_url.trim_end_matches('/')),
login_url: format!(
"{}/login/v2/flow/{}",
base_url.trim_end_matches('/'),
flow_token
),
})
}
pub fn flow_exists(&self, flow_token: &str) -> bool {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
state.flows.contains_key(flow_token)
}
pub fn complete(
&self,
flow_token: &str,
username: &str,
server: &str,
app_password: &str,
) -> bool {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
let pending = match state.flows.get_mut(flow_token) {
Some(pending) => pending,
None => return false,
};
pending.completed = Some(LoginResult {
server: server.to_string(),
login_name: username.to_string(),
app_password: app_password.to_string(),
});
true
}
pub fn poll(&self, poll_token: &str) -> Option<LoginResult> {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
prune_expired(&mut state, self.ttl);
let flow_token = state.poll_to_flow.get(poll_token).cloned()?;
let pending = state.flows.get_mut(&flow_token)?;
if let Some(result) = pending.completed.take() {
state.poll_to_flow.remove(poll_token);
state.flows.remove(&flow_token);
Some(result)
} else {
None
}
}
}
fn prune_expired(state: &mut FlowState, ttl: Duration) {
let now = Instant::now();
let expired: Vec<String> = state
.flows
.iter()
.filter(|(_, flow)| now.duration_since(flow.created_at) > ttl)
.map(|(token, _)| token.clone())
.collect();
for flow_token in expired {
if let Some(flow) = state.flows.remove(&flow_token) {
state.poll_to_flow.remove(&flow.poll_token);
}
}
}
fn random_hex(len: usize) -> String {
let mut bytes = vec![0u8; len.div_ceil(2)];
rand_core::OsRng.fill_bytes(&mut bytes);
let mut out = hex::encode(bytes);
out.truncate(len);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn service() -> NextcloudLoginFlowService {
NextcloudLoginFlowService::new(Duration::from_secs(600))
}
#[test]
fn test_initiate_returns_valid_tokens() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
assert!(!info.poll_token.is_empty());
assert!(
info.login_url
.starts_with("https://cloud.example.com/login/v2/flow/")
);
assert_eq!(
info.poll_endpoint,
"https://cloud.example.com/login/v2/poll"
);
}
#[test]
fn test_flow_exists_after_initiate() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
// Extract flow token from login URL.
let flow_token = info.login_url.rsplit('/').next().unwrap();
assert!(svc.flow_exists(flow_token));
}
#[test]
fn test_flow_not_found_for_unknown_token() {
let svc = service();
assert!(!svc.flow_exists("nonexistent-token"));
}
#[test]
fn test_poll_returns_none_before_completion() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
assert!(svc.poll(&info.poll_token).is_none());
}
#[test]
fn test_complete_and_poll_full_sequence() {
let svc = service();
let info = svc.initiate("https://cloud.example.com").unwrap();
let flow_token = info.login_url.rsplit('/').next().unwrap();
// Complete the flow.
let completed = svc.complete(
flow_token,
"alice",
"https://cloud.example.com",
"APP-PASS-12345",
);
assert!(completed);
// Poll should return the result exactly once.
let result = svc.poll(&info.poll_token).expect("should return result");
assert_eq!(result.login_name, "alice");
assert_eq!(result.server, "https://cloud.example.com");
assert_eq!(result.app_password, "APP-PASS-12345");
// Second poll should return None (consumed).
assert!(svc.poll(&info.poll_token).is_none());
}
#[test]
fn test_complete_unknown_flow_returns_false() {
let svc = service();
assert!(!svc.complete("nonexistent", "alice", "https://x.com", "pass"));
}
#[test]
fn test_expired_flows_are_pruned() {
let svc = NextcloudLoginFlowService::new(Duration::from_millis(1));
let info = svc.initiate("https://cloud.example.com").unwrap();
let flow_token = info.login_url.rsplit('/').next().unwrap();
// Wait for expiry.
std::thread::sleep(Duration::from_millis(10));
assert!(!svc.flow_exists(flow_token));
assert!(svc.poll(&info.poll_token).is_none());
}
#[test]
fn test_max_pending_flows_cap() {
let svc = NextcloudLoginFlowService::new(Duration::from_secs(600));
for _ in 0..MAX_PENDING_FLOWS {
svc.initiate("https://cloud.example.com").unwrap();
}
// The next initiate should fail
assert!(svc.initiate("https://cloud.example.com").is_err());
}
}
+263 -2
View File
@@ -436,10 +436,267 @@ mod tests {
use crate::application::dtos::share_dto::SharePermissionsDto;
use crate::application::ports::auth_ports::PasswordHasherPort;
use crate::application::ports::share_ports::ShareStoragePort;
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::config::AppConfig;
use crate::domain::repositories::folder_repository::FolderRepository;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
/// Test-only service that mirrors `ShareService` logic but accepts generic repos.
struct ShareServiceForTest<SR, FR, FoR, PH> {
config: Arc<AppConfig>,
share_repository: Arc<SR>,
file_repository: Arc<FR>,
folder_repository: Arc<FoR>,
password_hasher: Arc<PH>,
hash_semaphore: Arc<Semaphore>,
}
impl<SR, FR, FoR, PH> ShareServiceForTest<SR, FR, FoR, PH>
where
SR: ShareStoragePort,
FR: FileReadPort,
FoR: FolderRepository,
PH: PasswordHasherPort,
{
fn new(
config: Arc<AppConfig>,
share_repository: Arc<SR>,
file_repository: Arc<FR>,
folder_repository: Arc<FoR>,
password_hasher: Arc<PH>,
) -> Self {
Self {
config,
share_repository,
file_repository,
folder_repository,
password_hasher,
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
}
}
async fn verify_item_exists(
&self,
item_id: &str,
item_type: &ShareItemType,
) -> Result<(), ShareServiceError> {
match item_type {
ShareItemType::File => {
self.file_repository.get_file(item_id).await.map_err(|_| {
ShareServiceError::ItemNotFound(format!(
"File with ID {} not found",
item_id
))
})?;
}
ShareItemType::Folder => {
self.folder_repository
.get_folder(item_id)
.await
.map_err(|_| {
ShareServiceError::ItemNotFound(format!(
"Folder with ID {} not found",
item_id
))
})?;
}
}
Ok(())
}
async fn hash_password_async(&self, password: &str) -> Result<String, DomainError> {
let _permit = self.hash_semaphore.acquire().await.map_err(|_| {
DomainError::internal_error("ShareService", "Hash semaphore closed".to_string())
})?;
self.password_hasher.hash_password(password).await
}
}
impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH>
where
SR: ShareStoragePort,
FR: FileReadPort,
FoR: FolderRepository,
PH: PasswordHasherPort,
{
async fn create_shared_link(
&self,
user_id: &str,
dto: CreateShareDto,
) -> Result<ShareDto, DomainError> {
let item_type = ShareItemType::try_from(dto.item_type.as_str())
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
self.verify_item_exists(&dto.item_id, &item_type).await?;
let permissions = dto.permissions.map(|p| p.to_entity());
let password_hash = match dto.password {
Some(p) => Some(self.hash_password_async(&p).await?),
None => None,
};
let share = Share::new(
dto.item_id.clone(),
dto.item_name.clone(),
item_type,
user_id.to_string(),
permissions,
password_hash,
dto.expires_at,
)
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
let saved_share = self
.share_repository
.save_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
}
async fn get_shared_link(&self, id: &str) -> Result<ShareDto, DomainError> {
let share = self
.share_repository
.find_share_by_id(id)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
async fn get_shared_links_for_item(
&self,
item_id: &str,
item_type: &ShareItemType,
) -> Result<Vec<ShareDto>, DomainError> {
let shares = self
.share_repository
.find_shares_by_item(item_id, item_type)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(shares
.into_iter()
.filter(|s| !s.is_expired())
.map(|s| ShareDto::from_entity(&s, &self.config.base_url()))
.collect())
}
async fn update_shared_link(
&self,
id: &str,
dto: UpdateShareDto,
) -> Result<ShareDto, DomainError> {
let mut share = self
.share_repository
.find_share_by_id(id)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
})?;
if let Some(p) = dto.permissions {
share = share.with_permissions(SharePermissions::new(p.read, p.write, p.reshare));
}
if let Some(password) = dto.password {
let hash = if password.is_empty() {
None
} else {
Some(self.hash_password_async(&password).await?)
};
share = share.with_password(hash);
}
if dto.expires_at.is_some() {
share = share.with_expiration(dto.expires_at);
}
let updated = self
.share_repository
.update_share(&share)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(ShareDto::from_entity(&updated, &self.config.base_url()))
}
async fn delete_shared_link(&self, id: &str) -> Result<(), DomainError> {
self.share_repository
.delete_share(id)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(())
}
async fn get_user_shared_links(
&self,
user_id: &str,
page: usize,
per_page: usize,
) -> Result<PaginatedResponseDto<ShareDto>, DomainError> {
let offset = (page - 1) * per_page;
let (shares, total) = self
.share_repository
.find_shares_by_user(user_id, offset, per_page)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
let dtos = shares
.iter()
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
.collect();
Ok(PaginatedResponseDto::new(dtos, page, per_page, total))
}
async fn verify_shared_link_password(
&self,
token: &str,
password: &str,
) -> Result<bool, DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
match share.password_hash() {
Some(hash) => self.password_hasher.verify_password(password, hash).await,
None => Ok(true),
}
}
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
let updated = share.increment_access_count();
self.share_repository
.update_share(&updated)
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
Ok(())
}
}
struct MockPasswordHasher;
@@ -519,6 +776,10 @@ mod tests {
unimplemented!()
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
unimplemented!()
}
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
Ok(String::new())
}
@@ -831,7 +1092,7 @@ mod tests {
let password_hasher = Arc::new(MockPasswordHasher);
let service =
ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher);
ShareServiceForTest::new(config, share_repo, file_repo, folder_repo, password_hasher);
// Test creating a file share
let dto = CreateShareDto {
+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
);