Indexes: share_targets.target_id, duplicate_groups.status, GIN on stacks members + duplicate candidates JSONB, composite (owner_user_id, created_at DESC) on assets. N+1 elimination: batch metadata loading via find_by_assets(ids) using WHERE asset_id = ANY($1), used in timeline + sidecar export. Visibility: cache find_targets_for_user per request via OnceCell, extract filter_visible helper to reduce duplication. Streaming: FileStoragePort.open_file() returns (DataStream, u64), LocalFileStorage uses ReaderStream instead of loading full file. serve_file/serve_derivative use Body::from_stream(). Unbounded queries: sidecar full_export/import batched in 500-row chunks instead of u32::MAX. find_unresolved paginated with limit/offset. list_duplicates API accepts pagination params.
121 lines
4.2 KiB
Rust
121 lines
4.2 KiB
Rust
use async_trait::async_trait;
|
|
use bytes::Bytes;
|
|
use domain::errors::DomainError;
|
|
use domain::ports::{DataStream, FileEntry, FileStoragePort};
|
|
use futures::StreamExt;
|
|
use std::path::PathBuf;
|
|
use tokio_util::io::ReaderStream;
|
|
|
|
pub struct LocalFileStorage {
|
|
base_path: PathBuf,
|
|
}
|
|
|
|
impl LocalFileStorage {
|
|
pub fn new(base_path: impl Into<PathBuf>) -> Self {
|
|
Self {
|
|
base_path: base_path.into(),
|
|
}
|
|
}
|
|
|
|
fn resolve(&self, path: &str) -> Result<PathBuf, DomainError> {
|
|
let full = self.base_path.join(path);
|
|
// Prevent path traversal
|
|
if !full.starts_with(&self.base_path) {
|
|
return Err(DomainError::Validation(
|
|
"Path traversal not allowed".to_string(),
|
|
));
|
|
}
|
|
Ok(full)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl FileStoragePort for LocalFileStorage {
|
|
async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError> {
|
|
let full = self.resolve(path)?;
|
|
if let Some(parent) = full.parent() {
|
|
tokio::fs::create_dir_all(parent)
|
|
.await
|
|
.map_err(|e| DomainError::Internal(format!("Failed to create dirs: {e}")))?;
|
|
}
|
|
tokio::fs::write(&full, &data)
|
|
.await
|
|
.map_err(|e| DomainError::Internal(format!("Failed to write file: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_file(&self, path: &str) -> Result<Bytes, DomainError> {
|
|
let full = self.resolve(path)?;
|
|
let data = tokio::fs::read(&full).await.map_err(|e| match e.kind() {
|
|
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
|
_ => DomainError::Internal(format!("Failed to read file: {e}")),
|
|
})?;
|
|
Ok(Bytes::from(data))
|
|
}
|
|
|
|
async fn open_file(&self, path: &str) -> Result<(DataStream, u64), DomainError> {
|
|
let full = self.resolve(path)?;
|
|
let meta = tokio::fs::metadata(&full)
|
|
.await
|
|
.map_err(|e| match e.kind() {
|
|
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
|
_ => DomainError::Internal(format!("Failed to stat file: {e}")),
|
|
})?;
|
|
let file = tokio::fs::File::open(&full)
|
|
.await
|
|
.map_err(|e| match e.kind() {
|
|
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
|
_ => DomainError::Internal(format!("Failed to open file: {e}")),
|
|
})?;
|
|
let stream = ReaderStream::new(file)
|
|
.map(|r| r.map_err(|e| DomainError::Internal(format!("Read error: {e}"))));
|
|
Ok((Box::pin(stream), meta.len()))
|
|
}
|
|
|
|
async fn delete_file(&self, path: &str) -> Result<(), DomainError> {
|
|
let full = self.resolve(path)?;
|
|
match tokio::fs::remove_file(&full).await {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(DomainError::Internal(format!("Failed to delete file: {e}"))),
|
|
}
|
|
}
|
|
|
|
async fn list_directory(&self, path: &str) -> Result<Vec<FileEntry>, DomainError> {
|
|
let full = self.resolve(path)?;
|
|
let mut entries = Vec::new();
|
|
let mut read_dir = tokio::fs::read_dir(&full)
|
|
.await
|
|
.map_err(|e| match e.kind() {
|
|
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
|
|
_ => DomainError::Internal(format!("Failed to read dir: {e}")),
|
|
})?;
|
|
while let Some(entry) = read_dir
|
|
.next_entry()
|
|
.await
|
|
.map_err(|e| DomainError::Internal(e.to_string()))?
|
|
{
|
|
let meta = entry
|
|
.metadata()
|
|
.await
|
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
entries.push(FileEntry {
|
|
path: entry.file_name().to_string_lossy().to_string(),
|
|
size_bytes: meta.len(),
|
|
is_directory: meta.is_dir(),
|
|
});
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
async fn file_exists(&self, path: &str) -> Result<bool, DomainError> {
|
|
let full = self.resolve(path)?;
|
|
Ok(full.exists())
|
|
}
|
|
|
|
async fn available_space(&self) -> Result<u64, DomainError> {
|
|
// Simple stub: return a large number
|
|
Ok(u64::MAX)
|
|
}
|
|
}
|