From ebab30b1ea8c77c2391ec7873cd361e1603af9cf Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 9 May 2026 14:17:25 +0200 Subject: [PATCH] local file system --- .env.example | 41 ++++++-- README.md | 34 +++--- crates/adapters/poster-storage/src/config.rs | 104 ++++++++++++++----- crates/adapters/poster-storage/src/lib.rs | 4 +- crates/presentation/src/main.rs | 2 +- 5 files changed, 128 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index 39b73b1..88e9d61 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,34 @@ -DATABASE_URL=sqlite:./dev.db -BASE_URL=http://localhost:3000 +# Database +DATABASE_URL=sqlite://movies.db + +# Authentication +JWT_SECRET=change-me +JWT_TTL_SECONDS=86400 + +# OMDb metadata +OMDB_API_KEY=your-key + +# Poster storage — Option A (local) is active. To use S3, comment it out and uncomment Option B: + +# Option A: local filesystem (zero external dependencies) +POSTER_STORAGE_BACKEND=local +POSTER_STORAGE_PATH=./posters + +# Option B: S3-compatible (MinIO, AWS S3, etc.) +# POSTER_STORAGE_BACKEND=s3 +# MINIO_ENDPOINT=http://localhost:9000 +# MINIO_BUCKET=posters +# MINIO_REGION=minio +# MINIO_ACCESS_KEY_ID=minioadmin +# MINIO_SECRET_ACCESS_KEY=minioadmin + +# Optional +HOST=0.0.0.0 PORT=3000 +BASE_URL=http://localhost:3000 SECURE_COOKIES=false -JWT_SECRET= -JWT_TTL_SECONDS= -ALLOW_REGISTRATION=true -OMDB_API_KEY= +ALLOW_REGISTRATION=false +RATE_LIMIT=20 POSTER_FETCH_TIMEOUT_SECONDS=30 -MINIO_ENDPOINT= -MINIO_ACCESS_KEY_ID= -MINIO_SECRET_ACCESS_KEY= -MINIO_BUCKET= \ No newline at end of file +EVENT_CHANNEL_BUFFER=128 +RUST_LOG=presentation=debug,tower_http=debug diff --git a/README.md b/README.md index 119f47c..92de895 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A self-hosted, server-side rendered movie logging system. Built in Rust — no J - Log movies with a TMDB/OMDb ID and a 0–5 rating - Immutable append-only viewing ledger (tracks re-watches) -- Background poster fetching and storage (S3-compatible) +- Background poster fetching and storage (local filesystem or S3-compatible) - RSS/Atom feed for public subscription - JWT authentication via cookie (HTML) or Bearer token (REST API) - Zero JavaScript @@ -24,7 +24,7 @@ adapters/ sqlite — SQLite repository via sqlx metadata — OMDb HTTP client poster-fetcher — downloads poster images - poster-storage — uploads posters to S3-compatible storage + poster-storage — uploads posters to local filesystem or S3-compatible storage template-askama — Askama HTML rendering rss — RSS/Atom feed generation event-publisher — async event channel for background poster sync @@ -34,12 +34,12 @@ adapters/ - Rust (stable, 2024 edition) - SQLite -- An S3-compatible object store (e.g. MinIO) for poster storage +- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO) - An [OMDb API key](https://www.omdbapi.com/apikey.aspx) ## Environment Variables -Copy and fill in the following (e.g. in a `.env` file): +A `.env.example` file is provided at the repo root — copy it to `.env` and fill in your values. Key variables: ```env # Database @@ -47,25 +47,27 @@ DATABASE_URL=sqlite://movies.db # Authentication JWT_SECRET=change-me -JWT_TTL_SECONDS=86400 # OMDb metadata OMDB_API_KEY=your-key -# Poster storage (S3-compatible) -MINIO_ENDPOINT=http://localhost:9000 -MINIO_BUCKET=posters -MINIO_REGION=us-east-1 -MINIO_ACCESS_KEY_ID=minioadmin -MINIO_SECRET_ACCESS_KEY=minioadmin +# Poster storage — pick one backend: -# Optional -ALLOW_REGISTRATION=false -POSTER_FETCH_TIMEOUT_SECONDS=10 -EVENT_CHANNEL_BUFFER=32 -RUST_LOG=presentation=debug,tower_http=debug +# Option A: local filesystem (zero deps) +POSTER_STORAGE_BACKEND=local +POSTER_STORAGE_PATH=./posters + +# Option B: S3-compatible (MinIO, AWS S3, etc.) +# POSTER_STORAGE_BACKEND=s3 +# MINIO_ENDPOINT=http://localhost:9000 +# MINIO_BUCKET=posters +# MINIO_REGION=minio +# MINIO_ACCESS_KEY_ID=minioadmin +# MINIO_SECRET_ACCESS_KEY=minioadmin ``` +See `.env.example` for optional variables (rate limiting, logging, host/port, etc.). + ## Run ```bash diff --git a/crates/adapters/poster-storage/src/config.rs b/crates/adapters/poster-storage/src/config.rs index 11a1390..e78cff7 100644 --- a/crates/adapters/poster-storage/src/config.rs +++ b/crates/adapters/poster-storage/src/config.rs @@ -1,38 +1,86 @@ use anyhow::Context; -use object_store::{aws::AmazonS3Builder, ObjectStore}; +use object_store::{ObjectStore, aws::AmazonS3Builder, local::LocalFileSystem}; use std::sync::Arc; -pub struct StorageConfig { - endpoint: String, - access_key_id: String, - secret_access_key: String, - bucket: String, - region: String, -} +pub struct StorageConfig(Arc); impl StorageConfig { pub fn from_env() -> anyhow::Result { - Ok(Self { - endpoint: std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?, - access_key_id: std::env::var("MINIO_ACCESS_KEY_ID") - .context("MINIO_ACCESS_KEY_ID required")?, - secret_access_key: std::env::var("MINIO_SECRET_ACCESS_KEY") - .context("MINIO_SECRET_ACCESS_KEY required")?, - bucket: std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?, - region: std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()), - }) + let backend = std::env::var("POSTER_STORAGE_BACKEND") + .context("POSTER_STORAGE_BACKEND required (valid values: s3, local)")?; + + let store: Arc = match backend.as_str() { + "s3" => build_s3_store( + &std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?, + &std::env::var("MINIO_ACCESS_KEY_ID").context("MINIO_ACCESS_KEY_ID required")?, + &std::env::var("MINIO_SECRET_ACCESS_KEY") + .context("MINIO_SECRET_ACCESS_KEY required")?, + &std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?, + &std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()), + )?, + "local" => build_local_store( + &std::env::var("POSTER_STORAGE_PATH") + .context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?, + )?, + other => anyhow::bail!( + "Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local" + ), + }; + + Ok(Self(store)) } - pub fn build_store(self) -> anyhow::Result> { - let store = AmazonS3Builder::new() - .with_endpoint(self.endpoint) - .with_access_key_id(self.access_key_id) - .with_secret_access_key(self.secret_access_key) - .with_bucket_name(self.bucket) - .with_region(self.region) - .with_allow_http(true) - .build() - .context("Failed to build S3/Minio store")?; - Ok(Arc::new(store)) + pub fn build_store(self) -> Arc { + self.0 + } +} + +fn build_s3_store( + endpoint: &str, + access_key_id: &str, + secret_access_key: &str, + bucket: &str, + region: &str, +) -> anyhow::Result> { + let store = AmazonS3Builder::new() + .with_endpoint(endpoint) + .with_access_key_id(access_key_id) + .with_secret_access_key(secret_access_key) + .with_bucket_name(bucket) + .with_region(region) + .with_allow_http(true) + .build() + .context("Failed to build S3/Minio store")?; + Ok(Arc::new(store)) +} + +fn build_local_store(path: &str) -> anyhow::Result> { + std::fs::create_dir_all(path) + .context("Failed to create poster storage directory")?; + let store = LocalFileSystem::new_with_prefix(path) + .context("Failed to initialise local file system store")?; + Ok(Arc::new(store)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_store_creates_dir_and_succeeds() { + let dir = std::env::temp_dir() + .join(format!("poster_test_{}", uuid::Uuid::new_v4())); + let result = build_local_store(dir.to_str().unwrap()); + assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); + assert!(dir.exists(), "directory should have been created"); + } + + #[test] + fn local_store_succeeds_if_dir_already_exists() { + let dir = std::env::temp_dir() + .join(format!("poster_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let result = build_local_store(dir.to_str().unwrap()); + assert!(result.is_ok()); } } diff --git a/crates/adapters/poster-storage/src/lib.rs b/crates/adapters/poster-storage/src/lib.rs index a5f2514..a0b5896 100644 --- a/crates/adapters/poster-storage/src/lib.rs +++ b/crates/adapters/poster-storage/src/lib.rs @@ -25,8 +25,8 @@ impl PosterStorageAdapter { Self { store } } - pub fn from_config(config: StorageConfig) -> anyhow::Result { - Ok(Self::new(config.build_store()?)) + pub fn from_config(config: StorageConfig) -> Self { + Self::new(config.build_store()) } } diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index f05fcda..e1614a4 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -74,7 +74,7 @@ async fn wire_dependencies() -> anyhow::Result { let user_repository: Arc = Arc::new(SqliteUserRepository::new(pool.clone())); let metadata_client: Arc = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); let poster_fetcher: Arc = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); - let poster_storage: Arc = Arc::new(PosterStorageAdapter::from_config(storage_config)?); + let poster_storage: Arc = Arc::new(PosterStorageAdapter::from_config(storage_config)); let auth_service: Arc = Arc::new(JwtAuthService::new(auth_config)); let password_hasher: Arc = Arc::new(Argon2PasswordHasher);