style: cargo fmt --all
This commit is contained in:
@@ -3,6 +3,7 @@ pub mod pg;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
pub use pg::{
|
||||
AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository,
|
||||
FolderDbRepository, SessionPgRepository, TrashDbRepository, UserPgRepository,
|
||||
AppPasswordPgRepository, DeviceCodePgRepository, FileBlobReadRepository,
|
||||
FileBlobWriteRepository, FolderDbRepository, SessionPgRepository, TrashDbRepository,
|
||||
UserPgRepository,
|
||||
};
|
||||
|
||||
@@ -1,188 +1,178 @@
|
||||
//! PostgreSQL repository for App Passwords.
|
||||
|
||||
use crate::application::ports::auth_ports::AppPasswordStoragePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AppPasswordPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl AppPasswordPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppPasswordStoragePort for AppPasswordPgRepository {
|
||||
async fn create(&self, ap: AppPassword) -> Result<AppPassword, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.app_passwords
|
||||
(id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&ap.id)
|
||||
.bind(&ap.user_id)
|
||||
.bind(&ap.label)
|
||||
.bind(&ap.password_hash)
|
||||
.bind(&ap.prefix)
|
||||
.bind(&ap.scopes)
|
||||
.bind(ap.created_at)
|
||||
.bind(ap.last_used_at)
|
||||
.bind(ap.expires_at)
|
||||
.bind(ap.active)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("create: {e}")))?;
|
||||
|
||||
Ok(ap)
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("list: {e}")))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &str) -> Result<AppPassword, DomainError> {
|
||||
let row = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_by_id: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("AppPassword", id))?;
|
||||
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn get_active_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<AppPassword>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE user_id = $1
|
||||
AND active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("get_active: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("touch: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, id: &str) -> Result<(), DomainError> {
|
||||
let result =
|
||||
sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("revoke: {e}"))
|
||||
})?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("AppPassword", id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.app_passwords
|
||||
WHERE (active = FALSE)
|
||||
OR (expires_at IS NOT NULL AND expires_at < NOW())
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("delete_expired: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal row struct for sqlx mapping.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppPasswordRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
label: String,
|
||||
password_hash: String,
|
||||
prefix: String,
|
||||
scopes: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl From<AppPasswordRow> for AppPassword {
|
||||
fn from(r: AppPasswordRow) -> Self {
|
||||
AppPassword {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
label: r.label,
|
||||
password_hash: r.password_hash,
|
||||
prefix: r.prefix,
|
||||
scopes: r.scopes,
|
||||
created_at: r.created_at,
|
||||
last_used_at: r.last_used_at,
|
||||
expires_at: r.expires_at,
|
||||
active: r.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
//! PostgreSQL repository for App Passwords.
|
||||
|
||||
use crate::application::ports::auth_ports::AppPasswordStoragePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AppPasswordPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl AppPasswordPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppPasswordStoragePort for AppPasswordPgRepository {
|
||||
async fn create(&self, ap: AppPassword) -> Result<AppPassword, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.app_passwords
|
||||
(id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&ap.id)
|
||||
.bind(&ap.user_id)
|
||||
.bind(&ap.label)
|
||||
.bind(&ap.password_hash)
|
||||
.bind(&ap.prefix)
|
||||
.bind(&ap.scopes)
|
||||
.bind(ap.created_at)
|
||||
.bind(ap.last_used_at)
|
||||
.bind(ap.expires_at)
|
||||
.bind(ap.active)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("create: {e}")))?;
|
||||
|
||||
Ok(ap)
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("list: {e}")))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &str) -> Result<AppPassword, DomainError> {
|
||||
let row = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_by_id: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("AppPassword", id))?;
|
||||
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn get_active_by_user_id(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE user_id = $1
|
||||
AND active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("get_active: {e}")))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("touch: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, id: &str) -> Result<(), DomainError> {
|
||||
let result = sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("AppPassword", id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.app_passwords
|
||||
WHERE (active = FALSE)
|
||||
OR (expires_at IS NOT NULL AND expires_at < NOW())
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("delete_expired: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal row struct for sqlx mapping.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppPasswordRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
label: String,
|
||||
password_hash: String,
|
||||
prefix: String,
|
||||
scopes: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl From<AppPasswordRow> for AppPassword {
|
||||
fn from(r: AppPasswordRow) -> Self {
|
||||
AppPassword {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
label: r.label,
|
||||
password_hash: r.password_hash,
|
||||
prefix: r.prefix,
|
||||
scopes: r.scopes,
|
||||
created_at: r.created_at,
|
||||
last_used_at: r.last_used_at,
|
||||
expires_at: r.expires_at,
|
||||
active: r.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,261 +1,259 @@
|
||||
//! PostgreSQL repository for Device Authorization Grant (RFC 8628) codes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::auth_ports::DeviceCodeStoragePort;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
||||
|
||||
pub struct DeviceCodePgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl DeviceCodePgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<DeviceCode, DomainError> {
|
||||
let status_str: String = row.try_get("status").map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to read status: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = DeviceCodeStatus::from_str(&status_str).unwrap_or(DeviceCodeStatus::Expired);
|
||||
|
||||
Ok(DeviceCode::from_raw(
|
||||
row.try_get("id").unwrap_or_default(),
|
||||
row.try_get("device_code").unwrap_or_default(),
|
||||
row.try_get("user_code").unwrap_or_default(),
|
||||
row.try_get("client_name").unwrap_or_default(),
|
||||
row.try_get("scopes").unwrap_or_default(),
|
||||
status,
|
||||
row.try_get("user_id").ok(),
|
||||
row.try_get("access_token").ok(),
|
||||
row.try_get("refresh_token").ok(),
|
||||
row.try_get("verification_uri").unwrap_or_default(),
|
||||
row.try_get("verification_uri_complete").ok(),
|
||||
row.try_get("expires_at").unwrap_or_default(),
|
||||
row.try_get::<i32, _>("poll_interval_secs").unwrap_or(5),
|
||||
row.try_get("last_poll_at").ok(),
|
||||
row.try_get("created_at").unwrap_or_default(),
|
||||
row.try_get("authorized_at").ok(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceCodeStoragePort for DeviceCodePgRepository {
|
||||
async fn create_device_code(&self, dc: DeviceCode) -> Result<DeviceCode, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.device_codes (
|
||||
id, device_code, user_code, client_name, scopes, status,
|
||||
user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6::auth.device_code_status,
|
||||
$7, $8, $9,
|
||||
$10, $11,
|
||||
$12, $13, $14,
|
||||
$15, $16
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(dc.id())
|
||||
.bind(dc.device_code())
|
||||
.bind(dc.user_code())
|
||||
.bind(dc.client_name())
|
||||
.bind(dc.scopes())
|
||||
.bind(dc.status().as_str())
|
||||
.bind(dc.user_id())
|
||||
.bind(dc.access_token())
|
||||
.bind(dc.refresh_token())
|
||||
.bind(dc.verification_uri())
|
||||
.bind(dc.verification_uri_complete())
|
||||
.bind(dc.expires_at())
|
||||
.bind(dc.poll_interval_secs())
|
||||
.bind(dc.last_poll_at())
|
||||
.bind(dc.created_at())
|
||||
.bind(dc.authorized_at())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to create device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(dc)
|
||||
}
|
||||
|
||||
async fn get_by_device_code(&self, device_code: &str) -> Result<DeviceCode, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE device_code = $1
|
||||
"#,
|
||||
)
|
||||
.bind(device_code)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"Device code not found",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to fetch device code: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn get_pending_by_user_code(&self, user_code: &str) -> Result<DeviceCode, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE user_code = $1
|
||||
AND status = 'pending'
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(user_code)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"User code not found or expired",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to fetch by user code: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn update_device_code(&self, dc: DeviceCode) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.device_codes SET
|
||||
status = $2::auth.device_code_status,
|
||||
user_id = $3,
|
||||
access_token = $4,
|
||||
refresh_token = $5,
|
||||
last_poll_at = $6,
|
||||
authorized_at = $7
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(dc.id())
|
||||
.bind(dc.status().as_str())
|
||||
.bind(dc.user_id())
|
||||
.bind(dc.access_token())
|
||||
.bind(dc.refresh_token())
|
||||
.bind(dc.last_poll_at())
|
||||
.bind(dc.authorized_at())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to update device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.device_codes
|
||||
WHERE expires_at < NOW()
|
||||
AND status IN ('pending', 'expired')
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to delete expired device codes: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<DeviceCode>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to list device codes: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
rows.iter().map(Self::map_row).collect()
|
||||
}
|
||||
|
||||
async fn delete_by_id(&self, id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM auth.device_codes WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to delete device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
//! PostgreSQL repository for Device Authorization Grant (RFC 8628) codes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::auth_ports::DeviceCodeStoragePort;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
||||
|
||||
pub struct DeviceCodePgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl DeviceCodePgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<DeviceCode, DomainError> {
|
||||
let status_str: String = row.try_get("status").map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to read status: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = DeviceCodeStatus::from_str(&status_str).unwrap_or(DeviceCodeStatus::Expired);
|
||||
|
||||
Ok(DeviceCode::from_raw(
|
||||
row.try_get("id").unwrap_or_default(),
|
||||
row.try_get("device_code").unwrap_or_default(),
|
||||
row.try_get("user_code").unwrap_or_default(),
|
||||
row.try_get("client_name").unwrap_or_default(),
|
||||
row.try_get("scopes").unwrap_or_default(),
|
||||
status,
|
||||
row.try_get("user_id").ok(),
|
||||
row.try_get("access_token").ok(),
|
||||
row.try_get("refresh_token").ok(),
|
||||
row.try_get("verification_uri").unwrap_or_default(),
|
||||
row.try_get("verification_uri_complete").ok(),
|
||||
row.try_get("expires_at").unwrap_or_default(),
|
||||
row.try_get::<i32, _>("poll_interval_secs").unwrap_or(5),
|
||||
row.try_get("last_poll_at").ok(),
|
||||
row.try_get("created_at").unwrap_or_default(),
|
||||
row.try_get("authorized_at").ok(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeviceCodeStoragePort for DeviceCodePgRepository {
|
||||
async fn create_device_code(&self, dc: DeviceCode) -> Result<DeviceCode, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.device_codes (
|
||||
id, device_code, user_code, client_name, scopes, status,
|
||||
user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6::auth.device_code_status,
|
||||
$7, $8, $9,
|
||||
$10, $11,
|
||||
$12, $13, $14,
|
||||
$15, $16
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(dc.id())
|
||||
.bind(dc.device_code())
|
||||
.bind(dc.user_code())
|
||||
.bind(dc.client_name())
|
||||
.bind(dc.scopes())
|
||||
.bind(dc.status().as_str())
|
||||
.bind(dc.user_id())
|
||||
.bind(dc.access_token())
|
||||
.bind(dc.refresh_token())
|
||||
.bind(dc.verification_uri())
|
||||
.bind(dc.verification_uri_complete())
|
||||
.bind(dc.expires_at())
|
||||
.bind(dc.poll_interval_secs())
|
||||
.bind(dc.last_poll_at())
|
||||
.bind(dc.created_at())
|
||||
.bind(dc.authorized_at())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to create device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(dc)
|
||||
}
|
||||
|
||||
async fn get_by_device_code(&self, device_code: &str) -> Result<DeviceCode, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE device_code = $1
|
||||
"#,
|
||||
)
|
||||
.bind(device_code)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => {
|
||||
DomainError::new(ErrorKind::NotFound, "DeviceCode", "Device code not found")
|
||||
}
|
||||
_ => DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to fetch device code: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn get_pending_by_user_code(&self, user_code: &str) -> Result<DeviceCode, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE user_code = $1
|
||||
AND status = 'pending'
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(user_code)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"User code not found or expired",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to fetch by user code: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
Self::map_row(&row)
|
||||
}
|
||||
|
||||
async fn update_device_code(&self, dc: DeviceCode) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.device_codes SET
|
||||
status = $2::auth.device_code_status,
|
||||
user_id = $3,
|
||||
access_token = $4,
|
||||
refresh_token = $5,
|
||||
last_poll_at = $6,
|
||||
authorized_at = $7
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(dc.id())
|
||||
.bind(dc.status().as_str())
|
||||
.bind(dc.user_id())
|
||||
.bind(dc.access_token())
|
||||
.bind(dc.refresh_token())
|
||||
.bind(dc.last_poll_at())
|
||||
.bind(dc.authorized_at())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to update device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.device_codes
|
||||
WHERE expires_at < NOW()
|
||||
AND status IN ('pending', 'expired')
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to delete expired device codes: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<DeviceCode>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, device_code, user_code, client_name, scopes,
|
||||
status::text AS status, user_id, access_token, refresh_token,
|
||||
verification_uri, verification_uri_complete,
|
||||
expires_at, poll_interval_secs, last_poll_at,
|
||||
created_at, authorized_at
|
||||
FROM auth.device_codes
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to list device codes: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
rows.iter().map(Self::map_row).collect()
|
||||
}
|
||||
|
||||
async fn delete_by_id(&self, id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM auth.device_codes WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"DeviceCode",
|
||||
format!("Failed to delete device code: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,10 +268,18 @@ impl FolderRepository for FolderDbRepository {
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
||||
if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
@@ -281,15 +289,15 @@ impl FolderRepository for FolderDbRepository {
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
@@ -299,13 +307,13 @@ impl FolderRepository for FolderDbRepository {
|
||||
ORDER BY name
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
||||
|
||||
// total_count is identical in every row; 0 when the result set is empty.
|
||||
let total = if include_total {
|
||||
@@ -333,10 +341,18 @@ impl FolderRepository for FolderDbRepository {
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
||||
if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
@@ -346,16 +362,16 @@ impl FolderRepository for FolderDbRepository {
|
||||
ORDER BY name
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
@@ -365,16 +381,14 @@ impl FolderRepository for FolderDbRepository {
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}"))
|
||||
})?;
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||
|
||||
let total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.7) as usize)
|
||||
@@ -867,10 +881,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
user_id: &str,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let (where_extra, name_pattern) = match name_contains {
|
||||
Some(name) if name.len() >= 3 => (
|
||||
" AND fo.name ILIKE $3",
|
||||
Some(format!("%{}%", name)),
|
||||
),
|
||||
Some(name) if name.len() >= 3 => (" AND fo.name ILIKE $3", Some(format!("%{}%", name))),
|
||||
_ => ("", None),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user