fix: critical bugs from deep audit

- Fix copy_files() data loss: implement real copy_file across full stack
  (FileWritePort, FileManagementUseCase, stubs, service, repository with
  atomic CTE + dedup ref_count increment, batch_operations caller)
- Fix plaintext password in replace_default_admin: hash password via
  PasswordHasherPort before User::new()
- Fix CalendarService hardcoded user_id: unify CalendarUseCase trait with
  explicit user_id parameter on all methods, remove zombie _for_user
  duplicates and hardcoded 'current_user_id', update 20 CalDAV handler
  call sites
- Previous session: migrate DedupService to PostgreSQL (storage.blobs),
  atomic CTEs with compensation for file/folder repository operations
This commit is contained in:
Dionisio
2026-02-14 20:22:19 +01:00
parent 3179e1dd91
commit fac0b5e77b
17 changed files with 2163 additions and 2185 deletions
+2 -2
View File
@@ -5,6 +5,6 @@ pub mod pg;
// Re-exportar para facilitar acceso
pub use pg::{
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository,
SessionPgRepository, TrashDbRepository, UserPgRepository,
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, SessionPgRepository,
TrashDbRepository, UserPgRepository,
};
@@ -1,271 +1,265 @@
//! PostgreSQL + Blob-backed file read repository.
//!
//! Implements `FileReadPort` using:
//! - `storage.files` table for metadata lookups
//! - `DedupPort` for reading content-addressable blobs from the filesystem
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use sqlx::PgPool;
use std::sync::Arc;
use crate::application::ports::dedup_ports::DedupPort;
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath;
use super::folder_db_repository::FolderDbRepository;
/// File read repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobReadRepository {
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
}
impl FileBlobReadRepository {
pub fn new(
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
) -> Self {
Self {
pool,
dedup,
folder_repo,
}
}
/// Build a virtual StoragePath for a file.
async fn build_file_path(
&self,
folder_id: Option<&str>,
file_name: &str,
) -> Result<StoragePath, DomainError> {
if let Some(fid) = folder_id {
let folder_path = self.folder_repo.get_folder_path(fid).await?;
Ok(folder_path.join(file_name))
} else {
Ok(StoragePath::from_string(file_name))
}
}
/// Convert a database row into a `File` domain entity.
async fn row_to_file(
&self,
id: String,
name: String,
folder_id: Option<String>,
size: i64,
mime_type: String,
created_at: i64,
modified_at: i64,
) -> Result<File, DomainError> {
let storage_path = self
.build_file_path(folder_id.as_deref(), &name)
.await?;
File::with_timestamps(
id,
name,
storage_path,
size as u64,
mime_type,
folder_id,
created_at as u64,
modified_at as u64,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
/// Get the blob hash for a file.
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("File", file_id))
}
}
#[async_trait]
impl FileReadPort for FileBlobReadRepository {
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
SELECT id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?;
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
}
async fn list_files(
&self,
folder_id: Option<&str>,
) -> Result<Vec<File>, DomainError> {
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> =
if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.files
WHERE folder_id = $1::uuid AND NOT is_trashed
ORDER BY name
"#,
)
.bind(fid)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.files
WHERE folder_id IS NULL AND NOT is_trashed
ORDER BY name
"#,
)
.fetch_all(self.pool.as_ref())
.await
}
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
let mut files = Vec::with_capacity(rows.len());
for (id, name, fid, size, mime, ca, ma) in rows {
files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?);
}
Ok(files)
}
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
let blob_hash = self.get_blob_hash(id).await?;
self.dedup.read_blob(&blob_hash).await
}
async fn get_file_stream(
&self,
id: &str,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
// Read blob as bytes and wrap in a single-chunk stream.
// For very large files, a true streaming implementation from the
// blob file would be better, but DedupPort API currently returns bytes.
let blob_hash = self.get_blob_hash(id).await?;
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
let stream = futures::stream::once(async move { Ok(content) });
Ok(Box::new(stream))
}
async fn get_file_range_stream(
&self,
id: &str,
start: u64,
end: Option<u64>,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
let blob_hash = self.get_blob_hash(id).await?;
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
let start = start as usize;
let end = end.map_or(content.len(), |e| e as usize).min(content.len());
if start >= content.len() {
return Ok(Box::new(futures::stream::empty()));
}
let slice = content.slice(start..end);
let stream = futures::stream::once(async move { Ok(slice) });
Ok(Box::new(stream))
}
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
let blob_hash = self.get_blob_hash(id).await?;
self.dedup.read_blob_bytes(&blob_hash).await
}
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
let row = sqlx::query_as::<_, (String, Option<String>)>(
r#"
SELECT name, folder_id::text
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?;
self.build_file_path(row.1.as_deref(), &row.0).await
}
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
// Walk the path to find the parent folder, searching by folder names
let path = path.trim_start_matches('/').trim_end_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return Err(DomainError::not_found("Folder", "empty path"));
}
// For path "a/b/c/file.txt", the parent folder path is "a/b/c"
// But we don't know which part is folders vs filename.
// Walk segments trying to find matching folders.
let mut current_parent: Option<String> = None;
for segment in &segments {
let row = if let Some(ref pid) = current_parent {
sqlx::query_as::<_, (String,)>(
r#"
SELECT id::text FROM storage.folders
WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed
"#,
)
.bind(segment)
.bind(pid)
.fetch_optional(self.pool.as_ref())
.await
} else {
sqlx::query_as::<_, (String,)>(
r#"
SELECT id::text FROM storage.folders
WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed
"#,
)
.bind(segment)
.fetch_optional(self.pool.as_ref())
.await
}
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?;
match row {
Some(r) => current_parent = Some(r.0),
None => break, // This segment is not a folder → it's the filename
}
}
current_parent.ok_or_else(|| {
DomainError::not_found("Folder", format!("parent for path: {path}"))
})
}
}
//! PostgreSQL + Blob-backed file read repository.
//!
//! Implements `FileReadPort` using:
//! - `storage.files` table for metadata lookups
//! - `DedupPort` for reading content-addressable blobs from the filesystem
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use sqlx::PgPool;
use std::sync::Arc;
use crate::application::ports::dedup_ports::DedupPort;
use crate::application::ports::storage_ports::FileReadPort;
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath;
use super::folder_db_repository::FolderDbRepository;
/// File read repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobReadRepository {
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
}
impl FileBlobReadRepository {
pub fn new(
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
) -> Self {
Self {
pool,
dedup,
folder_repo,
}
}
/// Build a virtual StoragePath for a file.
async fn build_file_path(
&self,
folder_id: Option<&str>,
file_name: &str,
) -> Result<StoragePath, DomainError> {
if let Some(fid) = folder_id {
let folder_path = self.folder_repo.get_folder_path(fid).await?;
Ok(folder_path.join(file_name))
} else {
Ok(StoragePath::from_string(file_name))
}
}
/// Convert a database row into a `File` domain entity.
async fn row_to_file(
&self,
id: String,
name: String,
folder_id: Option<String>,
size: i64,
mime_type: String,
created_at: i64,
modified_at: i64,
) -> Result<File, DomainError> {
let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
File::with_timestamps(
id,
name,
storage_path,
size as u64,
mime_type,
folder_id,
created_at as u64,
modified_at as u64,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
/// Get the blob hash for a file.
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("File", file_id))
}
}
#[async_trait]
impl FileReadPort for FileBlobReadRepository {
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
SELECT id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?;
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
}
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> =
if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.files
WHERE folder_id = $1::uuid AND NOT is_trashed
ORDER BY name
"#,
)
.bind(fid)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
FROM storage.files
WHERE folder_id IS NULL AND NOT is_trashed
ORDER BY name
"#,
)
.fetch_all(self.pool.as_ref())
.await
}
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
let mut files = Vec::with_capacity(rows.len());
for (id, name, fid, size, mime, ca, ma) in rows {
files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?);
}
Ok(files)
}
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
let blob_hash = self.get_blob_hash(id).await?;
self.dedup.read_blob(&blob_hash).await
}
async fn get_file_stream(
&self,
id: &str,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
// Read blob as bytes and wrap in a single-chunk stream.
// For very large files, a true streaming implementation from the
// blob file would be better, but DedupPort API currently returns bytes.
let blob_hash = self.get_blob_hash(id).await?;
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
let stream = futures::stream::once(async move { Ok(content) });
Ok(Box::new(stream))
}
async fn get_file_range_stream(
&self,
id: &str,
start: u64,
end: Option<u64>,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
let blob_hash = self.get_blob_hash(id).await?;
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
let start = start as usize;
let end = end.map_or(content.len(), |e| e as usize).min(content.len());
if start >= content.len() {
return Ok(Box::new(futures::stream::empty()));
}
let slice = content.slice(start..end);
let stream = futures::stream::once(async move { Ok(slice) });
Ok(Box::new(stream))
}
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
let blob_hash = self.get_blob_hash(id).await?;
self.dedup.read_blob_bytes(&blob_hash).await
}
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
let row = sqlx::query_as::<_, (String, Option<String>)>(
r#"
SELECT name, folder_id::text
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?;
self.build_file_path(row.1.as_deref(), &row.0).await
}
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
// Walk the path to find the parent folder, searching by folder names
let path = path.trim_start_matches('/').trim_end_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return Err(DomainError::not_found("Folder", "empty path"));
}
// For path "a/b/c/file.txt", the parent folder path is "a/b/c"
// But we don't know which part is folders vs filename.
// Walk segments trying to find matching folders.
let mut current_parent: Option<String> = None;
for segment in &segments {
let row = if let Some(ref pid) = current_parent {
sqlx::query_as::<_, (String,)>(
r#"
SELECT id::text FROM storage.folders
WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed
"#,
)
.bind(segment)
.bind(pid)
.fetch_optional(self.pool.as_ref())
.await
} else {
sqlx::query_as::<_, (String,)>(
r#"
SELECT id::text FROM storage.folders
WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed
"#,
)
.bind(segment)
.fetch_optional(self.pool.as_ref())
.await
}
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?;
match row {
Some(r) => current_parent = Some(r.0),
None => break, // This segment is not a folder → it's the filename
}
}
current_parent
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
}
}
@@ -1,435 +1,526 @@
//! PostgreSQL + Blob-backed file write repository.
//!
//! Implements `FileWritePort` using:
//! - `storage.files` table for metadata
//! - `DedupPort` for content-addressable blob storage on the filesystem
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use sqlx::PgPool;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use crate::application::ports::dedup_ports::DedupPort;
use crate::application::ports::storage_ports::FileWritePort;
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath;
use super::folder_db_repository::FolderDbRepository;
/// File write repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobWriteRepository {
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
}
impl FileBlobWriteRepository {
pub fn new(
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
) -> Self {
Self {
pool,
dedup,
folder_repo,
}
}
/// Build a virtual StoragePath for a file from its DB metadata.
async fn build_file_path(
&self,
folder_id: Option<&str>,
file_name: &str,
) -> Result<StoragePath, DomainError> {
if let Some(fid) = folder_id {
let folder_path = self.folder_repo.get_folder_path(fid).await?;
Ok(folder_path.join(file_name))
} else {
Ok(StoragePath::from_string(file_name))
}
}
/// Convert a database row into a `File` domain entity.
async fn row_to_file(
&self,
id: String,
name: String,
folder_id: Option<String>,
size: i64,
mime_type: String,
created_at: i64,
modified_at: i64,
) -> Result<File, DomainError> {
let storage_path = self
.build_file_path(folder_id.as_deref(), &name)
.await?;
File::with_timestamps(
id,
name,
storage_path,
size as u64,
mime_type,
folder_id,
created_at as u64,
modified_at as u64,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
/// Derive user_id from the parent folder, or error if folder_id is None.
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<String, DomainError> {
match folder_id {
Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
None => Err(DomainError::internal_error(
"FileBlobWrite",
"folder_id is required to determine file owner",
)),
}
}
}
#[async_trait]
impl FileWritePort for FileBlobWriteRepository {
async fn save_file(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
content: Vec<u8>,
) -> Result<File, DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
let size = content.len() as i64;
// Store content in blob store
let dedup_result = self
.dedup
.store_bytes(&content, Some(content_type.clone()))
.await?;
let blob_hash = dedup_result.hash().to_string();
// Insert file metadata
let row = sqlx::query_as::<_, (String, i64, i64)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
VALUES ($1, $2::uuid, $3, $4, $5, $6)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(&user_id)
.bind(&blob_hash)
.bind(size)
.bind(&content_type)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.code().as_deref() == Some("23505") {
return DomainError::already_exists(
"File",
format!("{name} already exists in folder"),
);
}
}
DomainError::internal_error("FileBlobWrite", format!("insert: {e}"))
})?;
tracing::info!(
"💾 BLOB WRITE: {} ({} bytes, hash: {})",
name,
size,
&blob_hash[..12]
);
self.row_to_file(
row.0,
name,
folder_id,
size,
content_type,
row.1,
row.2,
)
.await
}
async fn save_file_from_stream(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
) -> Result<File, DomainError> {
use futures::StreamExt;
// Collect stream into bytes (blobs are content-addressed, need full content for hash)
let mut content = Vec::new();
let mut stream = stream;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("stream read: {e}"))
})?;
content.extend_from_slice(&chunk);
}
self.save_file(name, folder_id, content_type, content).await
}
async fn move_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<File, DomainError> {
// If moving to a different folder, get the new user_id (must be same user)
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
UPDATE storage.files
SET folder_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&target_folder_id)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
.ok_or_else(|| DomainError::not_found("File", file_id))?;
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
}
async fn rename_file(
&self,
file_id: &str,
new_name: &str,
) -> Result<File, DomainError> {
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
UPDATE storage.files
SET name = $1, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(new_name)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.code().as_deref() == Some("23505") {
return DomainError::already_exists(
"File",
format!("{new_name} already exists"),
);
}
}
DomainError::internal_error("FileBlobWrite", format!("rename: {e}"))
})?
.ok_or_else(|| DomainError::not_found("File", file_id))?;
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
// Get blob_hash before deleting so we can decrement ref
let hash = sqlx::query_scalar::<_, String>(
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid",
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("hash lookup: {e}")))?;
let result = sqlx::query("DELETE FROM storage.files WHERE id = $1::uuid")
.bind(id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", id));
}
// Decrement blob reference
if let Some(h) = hash {
if let Err(e) = self.dedup.remove_reference(&h).await {
tracing::warn!("Failed to decrement blob ref for {}: {}", &h[..12], e);
}
}
Ok(())
}
async fn update_file_content(
&self,
file_id: &str,
content: Vec<u8>,
) -> Result<(), DomainError> {
// Get old blob hash to decrement ref
let old_hash = sqlx::query_scalar::<_, String>(
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid",
)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("old hash: {e}")))?
.ok_or_else(|| DomainError::not_found("File", file_id))?;
// Store new content
let new_size = content.len() as i64;
let dedup_result = self.dedup.store_bytes(&content, None).await?;
let new_hash = dedup_result.hash().to_string();
// Update file metadata
sqlx::query(
r#"
UPDATE storage.files
SET blob_hash = $1, size = $2, updated_at = NOW()
WHERE id = $3::uuid
"#,
)
.bind(&new_hash)
.bind(new_size)
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("update: {e}")))?;
// Decrement old blob ref (only if hash changed)
if old_hash != new_hash {
if let Err(e) = self.dedup.remove_reference(&old_hash).await {
tracing::warn!(
"Failed to decrement old blob ref {}: {}",
&old_hash[..12],
e
);
}
}
Ok(())
}
async fn register_file_deferred(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
size: u64,
) -> Result<(File, PathBuf), DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
// For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
let row = sqlx::query_as::<_, (String, i64, i64)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
VALUES ($1, $2::uuid, $3, $4, $5, $6)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(&user_id)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
let file = self
.row_to_file(
row.0.clone(),
name,
folder_id,
size as i64,
content_type,
row.1,
row.2,
)
.await?;
// The target_path is not meaningful for blob storage (content goes to .blobs/)
// but the WriteBehindCache API requires it. We return a synthetic path.
let target_path = PathBuf::from(format!(".pending/{}", row.0));
Ok((file, target_path))
}
// ── Trash operations ──
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> {
let result = sqlx::query(
r#"
UPDATE storage.files
SET is_trashed = TRUE,
trashed_at = NOW(),
original_folder_id = folder_id,
updated_at = NOW()
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", file_id));
}
Ok(())
}
async fn restore_from_trash(
&self,
file_id: &str,
_original_path: &str,
) -> Result<(), DomainError> {
let result = sqlx::query(
r#"
UPDATE storage.files
SET is_trashed = FALSE,
trashed_at = NULL,
folder_id = COALESCE(original_folder_id, folder_id),
original_folder_id = NULL,
updated_at = NOW()
WHERE id = $1::uuid AND is_trashed
"#,
)
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", file_id));
}
Ok(())
}
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> {
// Same as delete_file — removes from DB and decrements blob ref
self.delete_file(file_id).await
}
}
//! PostgreSQL + Blob-backed file write repository.
//!
//! Implements `FileWritePort` using:
//! - `storage.files` table for metadata
//! - `DedupPort` for content-addressable blob storage on the filesystem
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use sqlx::PgPool;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use crate::application::ports::dedup_ports::DedupPort;
use crate::application::ports::storage_ports::FileWritePort;
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::path_service::StoragePath;
use super::folder_db_repository::FolderDbRepository;
/// File write repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobWriteRepository {
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
}
impl FileBlobWriteRepository {
pub fn new(
pool: Arc<PgPool>,
dedup: Arc<dyn DedupPort>,
folder_repo: Arc<FolderDbRepository>,
) -> Self {
Self {
pool,
dedup,
folder_repo,
}
}
/// Build a virtual StoragePath for a file from its DB metadata.
async fn build_file_path(
&self,
folder_id: Option<&str>,
file_name: &str,
) -> Result<StoragePath, DomainError> {
if let Some(fid) = folder_id {
let folder_path = self.folder_repo.get_folder_path(fid).await?;
Ok(folder_path.join(file_name))
} else {
Ok(StoragePath::from_string(file_name))
}
}
/// Convert a database row into a `File` domain entity.
async fn row_to_file(
&self,
id: String,
name: String,
folder_id: Option<String>,
size: i64,
mime_type: String,
created_at: i64,
modified_at: i64,
) -> Result<File, DomainError> {
let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
File::with_timestamps(
id,
name,
storage_path,
size as u64,
mime_type,
folder_id,
created_at as u64,
modified_at as u64,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
/// Derive user_id from the parent folder, or error if folder_id is None.
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<String, DomainError> {
match folder_id {
Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
None => Err(DomainError::internal_error(
"FileBlobWrite",
"folder_id is required to determine file owner",
)),
}
}
}
#[async_trait]
impl FileWritePort for FileBlobWriteRepository {
async fn save_file(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
content: Vec<u8>,
) -> Result<File, DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
let size = content.len() as i64;
// Store content in blob store
let dedup_result = self
.dedup
.store_bytes(&content, Some(content_type.clone()))
.await?;
let blob_hash = dedup_result.hash().to_string();
// Insert file metadata — if this fails, compensate by removing the blob ref
let row = match sqlx::query_as::<_, (String, i64, i64)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
VALUES ($1, $2::uuid, $3, $4, $5, $6)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(&user_id)
.bind(&blob_hash)
.bind(size)
.bind(&content_type)
.fetch_one(self.pool.as_ref())
.await
{
Ok(row) => row,
Err(e) => {
// ── Compensation: undo the blob ref so it doesn't become orphaned ──
if let Err(rollback_err) = self.dedup.remove_reference(&blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e {
if db_err.code().as_deref() == Some("23505") {
return Err(DomainError::already_exists(
"File",
format!("{name} already exists in folder"),
));
}
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
));
}
};
tracing::info!(
"💾 BLOB WRITE: {} ({} bytes, hash: {})",
name,
size,
&blob_hash[..12]
);
self.row_to_file(row.0, name, folder_id, size, content_type, row.1, row.2)
.await
}
async fn save_file_from_stream(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
) -> Result<File, DomainError> {
use futures::StreamExt;
// Collect stream into bytes (blobs are content-addressed, need full content for hash)
let mut content = Vec::new();
let mut stream = stream;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("stream read: {e}"))
})?;
content.extend_from_slice(&chunk);
}
self.save_file(name, folder_id, content_type, content).await
}
async fn move_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<File, DomainError> {
// If moving to a different folder, get the new user_id (must be same user)
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
UPDATE storage.files
SET folder_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&target_folder_id)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
.ok_or_else(|| DomainError::not_found("File", file_id))?;
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
}
async fn copy_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<File, DomainError> {
// Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count.
// Single round-trip; blob content is NOT copied (dedup makes this zero-copy).
let target_fid = target_folder_id.clone();
let row = sqlx::query_as::<
_,
(
String,
String,
Option<String>,
i64,
String,
i64,
i64,
String,
),
>(
r#"
WITH src AS (
SELECT name, folder_id, user_id, blob_hash, size, mime_type
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
),
new_file AS (
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
SELECT name,
COALESCE($2::uuid, folder_id),
user_id,
blob_hash,
size,
mime_type
FROM src
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
blob_hash
)
SELECT * FROM new_file
"#,
)
.bind(file_id)
.bind(&target_fid)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.code().as_deref() == Some("23505") {
return DomainError::already_exists(
"File",
"File with that name already exists in target folder".to_string(),
);
}
}
DomainError::internal_error("FileBlobWrite", format!("copy: {e}"))
})?
.ok_or_else(|| DomainError::not_found("File", file_id))?;
let blob_hash = &row.7;
// Increment blob reference count (best-effort; INSERT already succeeded)
if let Err(e) = self.dedup.add_reference(blob_hash).await {
tracing::warn!(
"Failed to increment blob ref for copy {}: {}",
&blob_hash[..12],
e
);
}
tracing::info!(
"📋 BLOB COPY: {} (hash: {}, zero-copy via dedup)",
row.1,
&blob_hash[..12]
);
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
}
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
UPDATE storage.files
SET name = $1, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(new_name)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e {
if db_err.code().as_deref() == Some("23505") {
return DomainError::already_exists(
"File",
format!("{new_name} already exists"),
);
}
}
DomainError::internal_error("FileBlobWrite", format!("rename: {e}"))
})?
.ok_or_else(|| DomainError::not_found("File", file_id))?;
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
.await
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
// Atomic DELETE RETURNING — one round-trip instead of SELECT + DELETE
let hash = sqlx::query_scalar::<_, String>(
"DELETE FROM storage.files WHERE id = $1::uuid RETURNING blob_hash",
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?
.ok_or_else(|| DomainError::not_found("File", id))?;
// Decrement blob reference (best-effort after successful DELETE)
if let Err(e) = self.dedup.remove_reference(&hash).await {
tracing::warn!("Failed to decrement blob ref for {}: {}", &hash[..12], e);
}
Ok(())
}
async fn update_file_content(
&self,
file_id: &str,
content: Vec<u8>,
) -> Result<(), DomainError> {
// Store new content first (blob store is idempotent)
let new_size = content.len() as i64;
let dedup_result = self.dedup.store_bytes(&content, None).await?;
let new_hash = dedup_result.hash().to_string();
// Atomic CTE: capture old hash then update in one round-trip, no TOCTOU.
// The `old` CTE locks + reads the row *before* the update touches it.
let old_hash = match sqlx::query_scalar::<_, String>(
r#"
WITH old AS (
SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE
)
UPDATE storage.files f
SET blob_hash = $1, size = $2, updated_at = NOW()
FROM old
WHERE f.id = old.id
RETURNING old.blob_hash
"#,
)
.bind(&new_hash)
.bind(new_size)
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
{
Ok(Some(old)) => old,
Ok(None) => {
// File not found — compensate: remove the new blob ref
if let Err(e) = self.dedup.remove_reference(&new_hash).await {
tracing::error!("Blob orphaned after missing file: {}", e);
}
return Err(DomainError::not_found("File", file_id));
}
Err(e) => {
// UPDATE failed — compensate: remove the new blob ref
if let Err(rollback_err) = self.dedup.remove_reference(&new_hash).await {
tracing::error!(
"Blob orphaned after failed UPDATE — hash: {}, err: {}",
&new_hash[..12],
rollback_err
);
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("update: {e}"),
));
}
};
// Decrement old blob ref (only if hash changed, best-effort)
if old_hash != new_hash {
if let Err(e) = self.dedup.remove_reference(&old_hash).await {
tracing::warn!(
"Failed to decrement old blob ref {}: {}",
&old_hash[..12],
e
);
}
}
Ok(())
}
async fn register_file_deferred(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
size: u64,
) -> Result<(File, PathBuf), DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
// For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
let row = sqlx::query_as::<_, (String, i64, i64)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
VALUES ($1, $2::uuid, $3, $4, $5, $6)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
"#,
)
.bind(&name)
.bind(&folder_id)
.bind(&user_id)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
let file = self
.row_to_file(
row.0.clone(),
name,
folder_id,
size as i64,
content_type,
row.1,
row.2,
)
.await?;
// The target_path is not meaningful for blob storage (content goes to .blobs/)
// but the WriteBehindCache API requires it. We return a synthetic path.
let target_path = PathBuf::from(format!(".pending/{}", row.0));
Ok((file, target_path))
}
// ── Trash operations ──
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> {
let result = sqlx::query(
r#"
UPDATE storage.files
SET is_trashed = TRUE,
trashed_at = NOW(),
original_folder_id = folder_id,
updated_at = NOW()
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", file_id));
}
Ok(())
}
async fn restore_from_trash(
&self,
file_id: &str,
_original_path: &str,
) -> Result<(), DomainError> {
let result = sqlx::query(
r#"
UPDATE storage.files
SET is_trashed = FALSE,
trashed_at = NULL,
folder_id = COALESCE(original_folder_id, folder_id),
original_folder_id = NULL,
updated_at = NOW()
WHERE id = $1::uuid AND is_trashed
"#,
)
.bind(file_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?;
if result.rows_affected() == 0 {
return Err(DomainError::not_found("File", file_id));
}
Ok(())
}
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> {
// Same as delete_file — removes from DB and decrements blob ref
self.delete_file(file_id).await
}
}
File diff suppressed because it is too large Load Diff
@@ -1,191 +1,173 @@
//! PostgreSQL-backed trash repository.
//!
//! Implements `TrashRepository` using soft-delete columns in `storage.files`
//! and `storage.folders`. There is no separate trash table — trashed items
//! are files/folders with `is_trashed = TRUE`.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::common::errors::{DomainError, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::trash_repository::TrashRepository;
/// Default retention period (days) used when computing deletion_date.
const _DEFAULT_RETENTION_DAYS: i64 = 30;
/// PostgreSQL-backed trash repository using soft-delete flags.
pub struct TrashDbRepository {
pool: Arc<PgPool>,
retention_days: i64,
}
impl TrashDbRepository {
pub fn new(pool: Arc<PgPool>, retention_days: u32) -> Self {
Self {
pool,
retention_days: retention_days as i64,
}
}
/// Convert a trash_items view row into a TrashedItem entity.
fn row_to_trashed_item(
&self,
id: Uuid,
name: String,
item_type: String,
user_id: String,
trashed_at: Option<DateTime<Utc>>,
) -> TrashedItem {
let trashed_at = trashed_at.unwrap_or_else(Utc::now);
let deletion_date = trashed_at + chrono::Duration::days(self.retention_days);
let item_type_enum = match item_type.as_str() {
"folder" => TrashedItemType::Folder,
_ => TrashedItemType::File,
};
let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil());
// In the soft-delete model, the trash entry ID is the same as the
// original item ID since there is no separate trash table.
TrashedItem::from_raw(
id, // trash entry id (same as original)
id, // original item id
user_uuid, // owner
item_type_enum,
name.clone(),
String::new(), // original_path — not stored separately in soft-delete model
trashed_at,
deletion_date,
)
}
}
#[async_trait]
impl TrashRepository for TrashDbRepository {
async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> {
// No-op: the actual flagging is done by FileWritePort::move_to_trash
// or FolderRepository::move_to_trash. This method exists for interface
// compatibility with the TrashService.
Ok(())
}
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
r#"
SELECT id, name, item_type, user_id, trashed_at
FROM storage.trash_items
WHERE user_id = $1
ORDER BY trashed_at DESC
"#,
)
.bind(user_id.to_string())
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("list: {e}"))
})?;
Ok(rows
.into_iter()
.map(|(id, name, item_type, uid, trashed_at)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
})
.collect())
}
async fn get_trash_item(
&self,
id: &Uuid,
user_id: &Uuid,
) -> Result<Option<TrashedItem>> {
let row = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
r#"
SELECT id, name, item_type, user_id, trashed_at
FROM storage.trash_items
WHERE id = $1 AND user_id = $2
"#,
)
.bind(id)
.bind(user_id.to_string())
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("get: {e}"))
})?;
Ok(row.map(|(id, name, item_type, uid, trashed_at)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
}))
}
async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
// No-op: the actual restore is done by FileWritePort::restore_from_trash
// or FolderRepository::restore_from_trash. The TrashService also removes
// the index entry — which in the soft-delete model means the flag is
// already cleared.
Ok(())
}
async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
// No-op: the actual delete is done by FileWritePort::delete_file_permanently
// or FolderRepository::delete_folder_permanently.
Ok(())
}
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
// Delete all trashed files for this user
sqlx::query(
"DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE",
)
.bind(user_id.to_string())
.execute(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("clear files: {e}"))
})?;
// Delete all trashed folders for this user
sqlx::query(
"DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE",
)
.bind(user_id.to_string())
.execute(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("clear folders: {e}"))
})?;
Ok(())
}
async fn get_expired_items(&self) -> Result<Vec<TrashedItem>> {
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
r#"
SELECT id, name, item_type, user_id, trashed_at
FROM storage.trash_items
WHERE trashed_at < $1
ORDER BY trashed_at ASC
"#,
)
.bind(cutoff)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("expired: {e}"))
})?;
Ok(rows
.into_iter()
.map(|(id, name, item_type, uid, trashed_at)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
})
.collect())
}
}
//! PostgreSQL-backed trash repository.
//!
//! Implements `TrashRepository` using soft-delete columns in `storage.files`
//! and `storage.folders`. There is no separate trash table — trashed items
//! are files/folders with `is_trashed = TRUE`.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::common::errors::{DomainError, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::trash_repository::TrashRepository;
/// Default retention period (days) used when computing deletion_date.
const _DEFAULT_RETENTION_DAYS: i64 = 30;
/// PostgreSQL-backed trash repository using soft-delete flags.
pub struct TrashDbRepository {
pool: Arc<PgPool>,
retention_days: i64,
}
impl TrashDbRepository {
pub fn new(pool: Arc<PgPool>, retention_days: u32) -> Self {
Self {
pool,
retention_days: retention_days as i64,
}
}
/// Convert a trash_items view row into a TrashedItem entity.
fn row_to_trashed_item(
&self,
id: Uuid,
name: String,
item_type: String,
user_id: String,
trashed_at: Option<DateTime<Utc>>,
) -> TrashedItem {
let trashed_at = trashed_at.unwrap_or_else(Utc::now);
let deletion_date = trashed_at + chrono::Duration::days(self.retention_days);
let item_type_enum = match item_type.as_str() {
"folder" => TrashedItemType::Folder,
_ => TrashedItemType::File,
};
let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil());
// In the soft-delete model, the trash entry ID is the same as the
// original item ID since there is no separate trash table.
TrashedItem::from_raw(
id, // trash entry id (same as original)
id, // original item id
user_uuid, // owner
item_type_enum,
name.clone(),
String::new(), // original_path — not stored separately in soft-delete model
trashed_at,
deletion_date,
)
}
}
#[async_trait]
impl TrashRepository for TrashDbRepository {
async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> {
// No-op: the actual flagging is done by FileWritePort::move_to_trash
// or FolderRepository::move_to_trash. This method exists for interface
// compatibility with the TrashService.
Ok(())
}
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
r#"
SELECT id, name, item_type, user_id, trashed_at
FROM storage.trash_items
WHERE user_id = $1
ORDER BY trashed_at DESC
"#,
)
.bind(user_id.to_string())
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
Ok(rows
.into_iter()
.map(|(id, name, item_type, uid, trashed_at)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
})
.collect())
}
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
let row = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
r#"
SELECT id, name, item_type, user_id, trashed_at
FROM storage.trash_items
WHERE id = $1 AND user_id = $2
"#,
)
.bind(id)
.bind(user_id.to_string())
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
Ok(row.map(|(id, name, item_type, uid, trashed_at)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
}))
}
async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
// No-op: the actual restore is done by FileWritePort::restore_from_trash
// or FolderRepository::restore_from_trash. The TrashService also removes
// the index entry — which in the soft-delete model means the flag is
// already cleared.
Ok(())
}
async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
// No-op: the actual delete is done by FileWritePort::delete_file_permanently
// or FolderRepository::delete_folder_permanently.
Ok(())
}
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
// Delete all trashed files for this user
sqlx::query("DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE")
.bind(user_id.to_string())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear files: {e}")))?;
// Delete all trashed folders for this user
sqlx::query("DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE")
.bind(user_id.to_string())
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear folders: {e}")))?;
Ok(())
}
async fn get_expired_items(&self) -> Result<Vec<TrashedItem>> {
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
r#"
SELECT id, name, item_type, user_id, trashed_at
FROM storage.trash_items
WHERE trashed_at < $1
ORDER BY trashed_at ASC
"#,
)
.bind(cutoff)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("expired: {e}")))?;
Ok(rows
.into_iter()
.map(|(id, name, item_type, uid, trashed_at)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
})
.collect())
}
}
+2 -7
View File
@@ -209,9 +209,7 @@ impl DedupService {
}
// Atomic write: temp file → rename
let temp_path = self
.temp_root
.join(format!("{}.tmp", uuid::Uuid::new_v4()));
let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4()));
fs::write(&temp_path, content).await.map_err(|e| {
DomainError::internal_error("Dedup", format!("Failed to write temp blob: {}", e))
})?;
@@ -258,10 +256,7 @@ impl DedupService {
let file_size = fs::metadata(source_path)
.await
.map_err(|e| {
DomainError::internal_error(
"Dedup",
format!("Failed to get file metadata: {}", e),
)
DomainError::internal_error("Dedup", format!("Failed to get file metadata: {}", e))
})?
.len();