Files
k-photos/crates/adapters/storage/src/local_file_storage.rs
Gabriel Kaszewski bcaf49cc81 perf: scale fixes for 1M+ photo libraries
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.
2026-05-31 22:40:25 +02:00

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)
}
}