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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user