optimize folder search: SQL-level filtering, user isolation, no in-memory filter; batch cascade trigger

This commit is contained in:
Dionisio
2026-02-24 17:15:36 +01:00
parent 6aa38d0d24
commit 28966ce28e
12 changed files with 361 additions and 131 deletions
+6 -2
View File
@@ -24,8 +24,12 @@ pub async fn create_auth_services(
config.auth.refresh_token_expiry_secs,
));
// Create password hashing service
let password_hasher = Arc::new(Argon2PasswordHasher::new());
// Create password hashing service with configured Argon2id parameters
let password_hasher = Arc::new(Argon2PasswordHasher::new(
config.auth.hash_memory_cost,
config.auth.hash_time_cost,
config.auth.hash_parallelism,
));
// Create PostgreSQL repositories
let user_repository = Arc::new(UserPgRepository::new(pool.clone()));
@@ -414,8 +414,9 @@ impl FolderRepository for FolderDbRepository {
}
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
// The BEFORE UPDATE trigger on `name` will recompute path/lpath
// and cascade to descendants automatically.
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
sqlx::query(
r#"
UPDATE storage.folders
@@ -444,8 +445,9 @@ impl FolderRepository for FolderDbRepository {
id: &str,
new_parent_id: Option<&str>,
) -> Result<Folder, DomainError> {
// The BEFORE UPDATE trigger on `parent_id` will recompute path/lpath
// and cascade to descendants automatically.
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
sqlx::query(
r#"
UPDATE storage.folders
@@ -561,8 +563,9 @@ impl FolderRepository for FolderDbRepository {
// Only restore the folder itself.
// Child files were never marked as trashed — they become visible
// again automatically once their parent folder is un-trashed.
// The BEFORE UPDATE trigger on parent_id will recompute path/lpath
// automatically when original_parent_id is restored.
// The BEFORE UPDATE trigger recomputes path/lpath when
// original_parent_id is restored; the cascade trigger
// batch-updates all descendants via the GiST lpath index.
let result = sqlx::query_scalar::<_, i64>(
r#"
WITH restore_folder AS (
@@ -711,6 +714,151 @@ impl FolderRepository for FolderDbRepository {
.collect()
}
/// SQL-level folder search with name filter, user isolation, and
/// recursive / non-recursive modes.
///
/// - Non-recursive: `WHERE parent_id = $1 AND user_id = $2 [AND LIKE]`
/// - Recursive + folder_id: delegates to `list_descendant_folders`
/// - Recursive + no folder_id: `WHERE user_id = $1 [AND LIKE]`
async fn search_folders(
&self,
parent_id: Option<&str>,
name_contains: Option<&str>,
user_id: &str,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder scope → existing optimised ltree scan
if recursive {
if let Some(fid) = parent_id {
return self.list_descendant_folders(fid, name_contains, user_id).await;
}
}
// Build optional name filter
let (name_clause, name_pattern) = match name_contains {
Some(name) if !name.is_empty() => (
if recursive {
" AND LOWER(fo.name) LIKE $2"
} else {
" AND LOWER(fo.name) LIKE $3"
},
Some(format!("%{}%", name.to_lowercase())),
),
_ => ("", None),
};
if recursive {
// Recursive, no folder scope → ALL user folders
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id::text, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
AND fo.is_trashed = false \
{name_clause} \
ORDER BY fo.name"
);
let rows: Vec<(String, String, String, Option<String>, Option<String>, i64, i64)> =
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("search_folders: {e}"))
})?;
return rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
})
.collect();
}
// Non-recursive: direct children of parent_id, filtered by user
let sql = if parent_id.is_some() {
format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id::text, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
FROM storage.folders fo \
WHERE fo.parent_id = $1::uuid \
AND fo.user_id = $2 \
AND fo.is_trashed = false \
{name_clause} \
ORDER BY fo.name"
)
} else {
// Root folders: parent_id IS NULL, reindex params ($1=user_id, $2=pattern)
let name_clause_root = match name_contains {
Some(name) if !name.is_empty() => " AND LOWER(fo.name) LIKE $2",
_ => "",
};
format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id::text, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND fo.user_id = $1 \
AND fo.is_trashed = false \
{name_clause_root} \
ORDER BY fo.name"
)
};
let rows: Vec<(String, String, String, Option<String>, Option<String>, i64, i64)> =
if let Some(pid) = parent_id {
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(pid)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(pid)
.bind(user_id)
.fetch_all(self.pool())
.await
}
} else if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("search_folders: {e}"))
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
})
.collect()
}
/// Lists all descendant folders in a subtree using ltree GiST index.
///
/// Single SQL query: `fo.lpath <@ (root's lpath)` fetches the entire
+45 -18
View File
@@ -3,12 +3,15 @@
//! This module provides a secure password hashing implementation using the Argon2id
//! algorithm, which is the recommended choice for password hashing as of 2023+.
//!
//! Both `hash_password` and `verify_password` are CPU-intensive (~300-500 ms with
//! default parameters) so they run inside `spawn_blocking` to avoid blocking Tokio
//! worker threads.
//! Both `hash_password` and `verify_password` are CPU-intensive so they run inside
//! `spawn_blocking` to avoid blocking Tokio worker threads.
//!
//! The Argon2id parameters (`m_cost`, `t_cost`, `p_cost`) are injected at
//! construction time from `AuthConfig`, so operators can tune security vs.
//! latency via environment variables.
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version};
use async_trait::async_trait;
use rand_core::OsRng;
@@ -20,23 +23,38 @@ use crate::common::errors::{DomainError, ErrorKind};
/// Uses Argon2id algorithm which provides resistance against both side-channel
/// and GPU-based attacks. This is the recommended algorithm for password hashing.
///
/// The struct is stateless — `Argon2::default()` is constructed per call inside
/// `spawn_blocking` so it is `Send` without extra synchronisation.
/// The struct stores the validated `Params` so that `Argon2` can be cheaply
/// reconstructed inside each `spawn_blocking` call (it is not `Send`).
#[derive(Debug, Clone)]
pub struct Argon2PasswordHasher {
_private: (),
params: Params,
}
impl Argon2PasswordHasher {
/// Create a new Argon2PasswordHasher with default secure parameters.
pub fn new() -> Self {
Self { _private: () }
}
}
/// Create a new hasher with explicit Argon2id parameters.
///
/// - `memory_cost`: memory in KiB (e.g. 65536 = 64 MiB)
/// - `time_cost`: number of iterations (e.g. 3)
/// - `parallelism`: lanes of parallelism (e.g. 2)
///
/// Panics at startup if the parameters are invalid (caught immediately).
pub fn new(memory_cost: u32, time_cost: u32, parallelism: u32) -> Self {
let params = Params::new(memory_cost, time_cost, parallelism, None)
.unwrap_or_else(|e| {
panic!(
"Invalid Argon2 parameters (m={}, t={}, p={}): {}",
memory_cost, time_cost, parallelism, e
)
});
impl Default for Argon2PasswordHasher {
fn default() -> Self {
Self::new()
tracing::info!(
"Argon2PasswordHasher initialized: m_cost={} KiB, t_cost={}, p_cost={}",
memory_cost,
time_cost,
parallelism,
);
Self { params }
}
}
@@ -44,9 +62,11 @@ impl Default for Argon2PasswordHasher {
impl PasswordHasherPort for Argon2PasswordHasher {
async fn hash_password(&self, password: &str) -> Result<String, DomainError> {
let pwd = password.to_owned();
let params = self.params.clone();
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
argon2
.hash_password(pwd.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|e| {
@@ -79,6 +99,8 @@ impl PasswordHasherPort for Argon2PasswordHasher {
)
})?;
// verify_password reads m/t/p from the hash string itself,
// so existing hashes (with old params) verify correctly.
Ok(Argon2::default()
.verify_password(pwd.as_bytes(), &parsed_hash)
.is_ok())
@@ -98,9 +120,14 @@ impl PasswordHasherPort for Argon2PasswordHasher {
mod tests {
use super::*;
/// Test params: small values so tests are fast (~10 ms instead of ~400 ms)
fn test_hasher() -> Argon2PasswordHasher {
Argon2PasswordHasher::new(16384, 1, 1)
}
#[tokio::test]
async fn test_hash_and_verify_password() {
let hasher = Argon2PasswordHasher::new();
let hasher = test_hasher();
let password = "test_password_123";
let hash = hasher
@@ -123,7 +150,7 @@ mod tests {
#[tokio::test]
async fn test_different_hashes_for_same_password() {
let hasher = Argon2PasswordHasher::new();
let hasher = test_hasher();
let password = "same_password";
let hash1 = hasher.hash_password(password).await.expect("Should hash");