style: cargo fmt --all

This commit is contained in:
Dionisio
2026-03-03 01:49:18 +01:00
parent 1df52fd702
commit efcf88c4d7
29 changed files with 2754 additions and 2732 deletions
+3 -1
View File
@@ -335,7 +335,9 @@ mod tests {
let claims1 = service.validate_token(&token).expect("Should validate");
// Second call: cache hit — skips HMAC, returns cloned claims
let claims2 = service.validate_token(&token).expect("Should validate from cache");
let claims2 = service
.validate_token(&token)
.expect("Should validate from cache");
assert_eq!(claims1.sub, claims2.sub);
assert_eq!(claims1.username, claims2.username);
@@ -1,153 +1,150 @@
//! Account lockout service — blocks login for an account after N consecutive
//! failed attempts.
//!
//! Uses a `moka` TTL cache so that:
//! * Failed-attempt counters automatically expire after the lockout window.
//! * No database writes are needed — this is **in-memory** and therefore
//! per-instance. If OxiCloud is deployed behind a load balancer with
//! multiple replicas, a sticky-session or shared Redis store would be
//! needed for cross-instance coordination (out of scope for v1).
//!
//! Typical flow:
//! 1. **Before password verification** → call [`LoginLockoutService::check`].
//! If the account is locked, return `403` immediately without touching
//! Argon2 (saves CPU).
//! 2. **After failed verification** → call [`LoginLockoutService::record_failure`].
//! 3. **After successful login** → call [`LoginLockoutService::record_success`]
//! to reset the counter.
use moka::sync::Cache;
use std::time::Duration;
/// Tracks consecutive failures for a single username.
#[derive(Clone, Debug)]
struct FailureRecord {
/// Number of consecutive failed attempts.
count: u32,
}
/// In-memory account lockout tracker.
#[derive(Clone)]
pub struct LoginLockoutService {
/// Maps `username -> FailureRecord`. TTL = lockout window.
cache: Cache<String, FailureRecord>,
/// Maximum consecutive failures before the account is temporarily locked.
max_failures: u32,
/// How long the lockout lasts (seconds).
lockout_secs: u64,
}
impl LoginLockoutService {
/// Create a new lockout service.
///
/// * `max_failures` — e.g. `5` (lock after 5 bad passwords)
/// * `lockout_secs` — e.g. `900` (15-minute lockout)
/// * `max_accounts` — upper bound on tracked accounts (evicts LRU)
pub fn new(max_failures: u32, lockout_secs: u64, max_accounts: u64) -> Self {
let cache = Cache::builder()
.time_to_live(Duration::from_secs(lockout_secs))
.max_capacity(max_accounts)
.build();
Self {
cache,
max_failures,
lockout_secs,
}
}
/// Check whether the account is currently locked.
///
/// Returns `Ok(())` if the user may attempt login, or
/// `Err(remaining_secs)` with the *approximate* remaining lockout time.
pub fn check(&self, username: &str) -> Result<(), u64> {
if let Some(rec) = self.cache.get(&username.to_lowercase()) {
if rec.count >= self.max_failures {
// The entry exists and is over the threshold. Because moka
// evicts at TTL we know the lockout window has not yet elapsed.
return Err(self.lockout_secs);
}
}
Ok(())
}
/// Record a failed login attempt. Returns the new failure count.
pub fn record_failure(&self, username: &str) -> u32 {
let key = username.to_lowercase();
let new_count = self
.cache
.get(&key)
.map(|r| r.count + 1)
.unwrap_or(1);
self.cache.insert(key.clone(), FailureRecord { count: new_count });
if new_count >= self.max_failures {
tracing::warn!(
username = %username,
attempts = new_count,
lockout_secs = self.lockout_secs,
"Account temporarily locked after {} consecutive failed login attempts",
new_count,
);
}
new_count
}
/// Record a successful login — resets the failure counter.
pub fn record_success(&self, username: &str) {
self.cache.invalidate(&username.to_lowercase());
}
/// Maximum failures before lockout (used to inform callers / error messages).
pub fn max_failures(&self) -> u32 {
self.max_failures
}
/// Lockout duration in seconds.
pub fn lockout_secs(&self) -> u64 {
self.lockout_secs
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_login_under_threshold() {
let svc = LoginLockoutService::new(3, 60, 100);
assert!(svc.check("alice").is_ok());
svc.record_failure("alice");
svc.record_failure("alice");
// 2 failures — still under threshold
assert!(svc.check("alice").is_ok());
}
#[test]
fn locks_after_threshold() {
let svc = LoginLockoutService::new(3, 60, 100);
svc.record_failure("bob");
svc.record_failure("bob");
svc.record_failure("bob");
assert!(svc.check("bob").is_err());
}
#[test]
fn resets_on_success() {
let svc = LoginLockoutService::new(3, 60, 100);
svc.record_failure("carol");
svc.record_failure("carol");
svc.record_success("carol");
// Counter reset — should be allowed again
assert!(svc.check("carol").is_ok());
svc.record_failure("carol"); // starts over at 1
assert!(svc.check("carol").is_ok());
}
#[test]
fn case_insensitive() {
let svc = LoginLockoutService::new(2, 60, 100);
svc.record_failure("Dave");
svc.record_failure("dave");
assert!(svc.check("DAVE").is_err());
}
}
//! Account lockout service — blocks login for an account after N consecutive
//! failed attempts.
//!
//! Uses a `moka` TTL cache so that:
//! * Failed-attempt counters automatically expire after the lockout window.
//! * No database writes are needed — this is **in-memory** and therefore
//! per-instance. If OxiCloud is deployed behind a load balancer with
//! multiple replicas, a sticky-session or shared Redis store would be
//! needed for cross-instance coordination (out of scope for v1).
//!
//! Typical flow:
//! 1. **Before password verification** → call [`LoginLockoutService::check`].
//! If the account is locked, return `403` immediately without touching
//! Argon2 (saves CPU).
//! 2. **After failed verification** → call [`LoginLockoutService::record_failure`].
//! 3. **After successful login** → call [`LoginLockoutService::record_success`]
//! to reset the counter.
use moka::sync::Cache;
use std::time::Duration;
/// Tracks consecutive failures for a single username.
#[derive(Clone, Debug)]
struct FailureRecord {
/// Number of consecutive failed attempts.
count: u32,
}
/// In-memory account lockout tracker.
#[derive(Clone)]
pub struct LoginLockoutService {
/// Maps `username -> FailureRecord`. TTL = lockout window.
cache: Cache<String, FailureRecord>,
/// Maximum consecutive failures before the account is temporarily locked.
max_failures: u32,
/// How long the lockout lasts (seconds).
lockout_secs: u64,
}
impl LoginLockoutService {
/// Create a new lockout service.
///
/// * `max_failures` — e.g. `5` (lock after 5 bad passwords)
/// * `lockout_secs` — e.g. `900` (15-minute lockout)
/// * `max_accounts` — upper bound on tracked accounts (evicts LRU)
pub fn new(max_failures: u32, lockout_secs: u64, max_accounts: u64) -> Self {
let cache = Cache::builder()
.time_to_live(Duration::from_secs(lockout_secs))
.max_capacity(max_accounts)
.build();
Self {
cache,
max_failures,
lockout_secs,
}
}
/// Check whether the account is currently locked.
///
/// Returns `Ok(())` if the user may attempt login, or
/// `Err(remaining_secs)` with the *approximate* remaining lockout time.
pub fn check(&self, username: &str) -> Result<(), u64> {
if let Some(rec) = self.cache.get(&username.to_lowercase()) {
if rec.count >= self.max_failures {
// The entry exists and is over the threshold. Because moka
// evicts at TTL we know the lockout window has not yet elapsed.
return Err(self.lockout_secs);
}
}
Ok(())
}
/// Record a failed login attempt. Returns the new failure count.
pub fn record_failure(&self, username: &str) -> u32 {
let key = username.to_lowercase();
let new_count = self.cache.get(&key).map(|r| r.count + 1).unwrap_or(1);
self.cache
.insert(key.clone(), FailureRecord { count: new_count });
if new_count >= self.max_failures {
tracing::warn!(
username = %username,
attempts = new_count,
lockout_secs = self.lockout_secs,
"Account temporarily locked after {} consecutive failed login attempts",
new_count,
);
}
new_count
}
/// Record a successful login — resets the failure counter.
pub fn record_success(&self, username: &str) {
self.cache.invalidate(&username.to_lowercase());
}
/// Maximum failures before lockout (used to inform callers / error messages).
pub fn max_failures(&self) -> u32 {
self.max_failures
}
/// Lockout duration in seconds.
pub fn lockout_secs(&self) -> u64 {
self.lockout_secs
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_login_under_threshold() {
let svc = LoginLockoutService::new(3, 60, 100);
assert!(svc.check("alice").is_ok());
svc.record_failure("alice");
svc.record_failure("alice");
// 2 failures — still under threshold
assert!(svc.check("alice").is_ok());
}
#[test]
fn locks_after_threshold() {
let svc = LoginLockoutService::new(3, 60, 100);
svc.record_failure("bob");
svc.record_failure("bob");
svc.record_failure("bob");
assert!(svc.check("bob").is_err());
}
#[test]
fn resets_on_success() {
let svc = LoginLockoutService::new(3, 60, 100);
svc.record_failure("carol");
svc.record_failure("carol");
svc.record_success("carol");
// Counter reset — should be allowed again
assert!(svc.check("carol").is_ok());
svc.record_failure("carol"); // starts over at 1
assert!(svc.check("carol").is_ok());
}
#[test]
fn case_insensitive() {
let svc = LoginLockoutService::new(2, 60, 100);
svc.record_failure("Dave");
svc.record_failure("dave");
assert!(svc.check("DAVE").is_err());
}
}
+1 -1
View File
@@ -1,11 +1,11 @@
pub mod chunked_upload_service;
pub mod compression_service;
pub mod dedup_service;
pub mod login_lockout_service;
pub mod file_content_cache;
pub mod file_system_i18n_service;
pub mod image_transcode_service;
pub mod jwt_service;
pub mod login_lockout_service;
pub mod oidc_service;
pub mod password_hasher;
pub mod path_resolver_service;
@@ -1,205 +1,219 @@
//! Single-query WebDAV path resolver.
//!
//! Replaces the double-query pattern (`get_folder_by_path` + `get_file_by_path`)
//! with a single `UNION ALL` query that returns the first match. PostgreSQL's
//! `Append` node short-circuits on `LIMIT 1`, so if the folder branch matches
//! the file branch is never executed.
use sqlx::PgPool;
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::common::errors::DomainError;
/// Result of resolving a WebDAV path — either a folder or a file.
#[derive(Debug, Clone)]
pub enum ResolvedResource {
Folder(FolderDto),
File(FileDto),
}
/// Resolves a WebDAV path to a folder or file in a single SQL round-trip.
pub struct PathResolverService {
pool: Arc<PgPool>,
}
impl PathResolverService {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Resolve `path` (without leading `/`) to either a folder or a file.
///
/// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated
/// first, and PG short-circuits if it produces a row.
pub async fn resolve_path(&self, path: &str) -> Result<ResolvedResource, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
return Err(DomainError::not_found("Resource", "empty path"));
}
// Split into folder_path + filename for the file branch
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let filename = segments[segments.len() - 1];
let folder_path = if segments.len() > 1 {
segments[..segments.len() - 1].join("/")
} else {
String::new()
};
// Single round-trip: folder branch ∪ file branch, LIMIT 1.
// Column order: resource_type, id, name, path, parent_id, user_id,
// created_at, modified_at, size, mime_type, folder_id
let row = sqlx::query_as::<_, (
String, // resource_type
String, // id
String, // name
String, // path
Option<String>, // parent_id (folder) / NULL (file)
Option<String>, // user_id
i64, // created_at epoch
i64, // modified_at epoch
Option<i64>, // size (NULL for folder)
Option<String>, // mime_type (NULL for folder)
Option<String>, // folder_id (NULL for folder)
)>(
r#"
SELECT resource_type, id, name, path, parent_id, user_id,
created_at, modified_at, size, mime_type, folder_id
FROM (
SELECT 'folder'::text AS resource_type,
fo.id::text,
fo.name,
fo.path,
fo.parent_id::text,
fo.user_id::text,
EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
NULL::bigint AS size,
NULL::text AS mime_type,
NULL::text AS folder_id
FROM storage.folders fo
WHERE fo.path = $1 AND NOT fo.is_trashed
UNION ALL
SELECT 'file'::text AS resource_type,
fi.id::text,
fi.name,
CASE
WHEN fo.path IS NOT NULL AND fo.path != ''
THEN fo.path || '/' || fi.name
ELSE fi.name
END AS path,
NULL::text AS parent_id,
fi.user_id::text,
EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
fi.size,
fi.mime_type,
fi.folder_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $2
AND (
($3 = '' AND fi.folder_id IS NULL)
OR fo.path = $3
)
AND NOT fi.is_trashed
) sub
LIMIT 1
"#,
)
.bind(path) // $1 — full path for folder lookup
.bind(filename) // $2 — filename for file lookup
.bind(&folder_path) // $3 — parent folder path for file lookup
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))?
.ok_or_else(|| DomainError::not_found("Resource", path))?;
let (resource_type, id, name, res_path, parent_id, user_id,
created_at, modified_at, size, mime_type, folder_id) = row;
match resource_type.as_str() {
"folder" => Ok(ResolvedResource::Folder(FolderDto {
id,
name: name.clone(),
path: res_path,
parent_id,
owner_id: user_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
is_root: false,
icon_class: "fas fa-folder".to_string(),
icon_special_class: "folder-icon".to_string(),
category: "Folder".to_string(),
})),
_ => {
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
let sz = size.unwrap_or(0) as u64;
Ok(ResolvedResource::File(FileDto {
id,
name: name.clone(),
path: res_path,
size: sz,
mime_type: mime.clone(),
folder_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
icon_class: icon_class_for(&name, &mime).to_string(),
icon_special_class: icon_special_class_for(&name, &mime).to_string(),
category: category_for(&name, &mime).to_string(),
size_formatted: format_file_size(sz),
owner_id: user_id,
}))
}
}
}
/// Check whether *any* resource (folder or file) exists at the given path.
///
/// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO.
pub async fn exists(&self, path: &str) -> Result<bool, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
return Ok(false);
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let filename = segments[segments.len() - 1];
let folder_path = if segments.len() > 1 {
segments[..segments.len() - 1].join("/")
} else {
String::new()
};
let exists = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM storage.folders
WHERE path = $1 AND NOT is_trashed
) OR EXISTS(
SELECT 1
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $2
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
AND NOT fi.is_trashed
)
"#,
)
.bind(path)
.bind(filename)
.bind(&folder_path)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?;
Ok(exists)
}
}
//! Single-query WebDAV path resolver.
//!
//! Replaces the double-query pattern (`get_folder_by_path` + `get_file_by_path`)
//! with a single `UNION ALL` query that returns the first match. PostgreSQL's
//! `Append` node short-circuits on `LIMIT 1`, so if the folder branch matches
//! the file branch is never executed.
use sqlx::PgPool;
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::common::errors::DomainError;
/// Result of resolving a WebDAV path — either a folder or a file.
#[derive(Debug, Clone)]
pub enum ResolvedResource {
Folder(FolderDto),
File(FileDto),
}
/// Resolves a WebDAV path to a folder or file in a single SQL round-trip.
pub struct PathResolverService {
pool: Arc<PgPool>,
}
impl PathResolverService {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
/// Resolve `path` (without leading `/`) to either a folder or a file.
///
/// The query uses `UNION ALL … LIMIT 1`: the folder branch is evaluated
/// first, and PG short-circuits if it produces a row.
pub async fn resolve_path(&self, path: &str) -> Result<ResolvedResource, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
return Err(DomainError::not_found("Resource", "empty path"));
}
// Split into folder_path + filename for the file branch
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let filename = segments[segments.len() - 1];
let folder_path = if segments.len() > 1 {
segments[..segments.len() - 1].join("/")
} else {
String::new()
};
// Single round-trip: folder branch ∪ file branch, LIMIT 1.
// Column order: resource_type, id, name, path, parent_id, user_id,
// created_at, modified_at, size, mime_type, folder_id
let row = sqlx::query_as::<
_,
(
String, // resource_type
String, // id
String, // name
String, // path
Option<String>, // parent_id (folder) / NULL (file)
Option<String>, // user_id
i64, // created_at epoch
i64, // modified_at epoch
Option<i64>, // size (NULL for folder)
Option<String>, // mime_type (NULL for folder)
Option<String>, // folder_id (NULL for folder)
),
>(
r#"
SELECT resource_type, id, name, path, parent_id, user_id,
created_at, modified_at, size, mime_type, folder_id
FROM (
SELECT 'folder'::text AS resource_type,
fo.id::text,
fo.name,
fo.path,
fo.parent_id::text,
fo.user_id::text,
EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
NULL::bigint AS size,
NULL::text AS mime_type,
NULL::text AS folder_id
FROM storage.folders fo
WHERE fo.path = $1 AND NOT fo.is_trashed
UNION ALL
SELECT 'file'::text AS resource_type,
fi.id::text,
fi.name,
CASE
WHEN fo.path IS NOT NULL AND fo.path != ''
THEN fo.path || '/' || fi.name
ELSE fi.name
END AS path,
NULL::text AS parent_id,
fi.user_id::text,
EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
fi.size,
fi.mime_type,
fi.folder_id::text
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $2
AND (
($3 = '' AND fi.folder_id IS NULL)
OR fo.path = $3
)
AND NOT fi.is_trashed
) sub
LIMIT 1
"#,
)
.bind(path) // $1 — full path for folder lookup
.bind(filename) // $2 — filename for file lookup
.bind(&folder_path) // $3 — parent folder path for file lookup
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve: {e}")))?
.ok_or_else(|| DomainError::not_found("Resource", path))?;
let (
resource_type,
id,
name,
res_path,
parent_id,
user_id,
created_at,
modified_at,
size,
mime_type,
folder_id,
) = row;
match resource_type.as_str() {
"folder" => Ok(ResolvedResource::Folder(FolderDto {
id,
name: name.clone(),
path: res_path,
parent_id,
owner_id: user_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
is_root: false,
icon_class: "fas fa-folder".to_string(),
icon_special_class: "folder-icon".to_string(),
category: "Folder".to_string(),
})),
_ => {
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
let sz = size.unwrap_or(0) as u64;
Ok(ResolvedResource::File(FileDto {
id,
name: name.clone(),
path: res_path,
size: sz,
mime_type: mime.clone(),
folder_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
icon_class: icon_class_for(&name, &mime).to_string(),
icon_special_class: icon_special_class_for(&name, &mime).to_string(),
category: category_for(&name, &mime).to_string(),
size_formatted: format_file_size(sz),
owner_id: user_id,
}))
}
}
}
/// Check whether *any* resource (folder or file) exists at the given path.
///
/// Equivalent to `resolve_path(…).is_ok()` but avoids constructing the DTO.
pub async fn exists(&self, path: &str) -> Result<bool, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
return Ok(false);
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let filename = segments[segments.len() - 1];
let folder_path = if segments.len() > 1 {
segments[..segments.len() - 1].join("/")
} else {
String::new()
};
let exists = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM storage.folders
WHERE path = $1 AND NOT is_trashed
) OR EXISTS(
SELECT 1
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $2
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
AND NOT fi.is_trashed
)
"#,
)
.bind(path)
.bind(filename)
.bind(&folder_path)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists: {e}")))?;
Ok(exists)
}
}