local file system

This commit is contained in:
2026-05-09 14:17:25 +02:00
parent 0d3c2c937d
commit b0ce316c30
5 changed files with 128 additions and 57 deletions

View File

@@ -1,13 +1,34 @@
DATABASE_URL=sqlite:./dev.db # Database
BASE_URL=http://localhost:3000 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 PORT=3000
BASE_URL=http://localhost:3000
SECURE_COOKIES=false SECURE_COOKIES=false
JWT_SECRET= ALLOW_REGISTRATION=false
JWT_TTL_SECONDS= RATE_LIMIT=20
ALLOW_REGISTRATION=true
OMDB_API_KEY=
POSTER_FETCH_TIMEOUT_SECONDS=30 POSTER_FETCH_TIMEOUT_SECONDS=30
MINIO_ENDPOINT= EVENT_CHANNEL_BUFFER=128
MINIO_ACCESS_KEY_ID= RUST_LOG=presentation=debug,tower_http=debug
MINIO_SECRET_ACCESS_KEY=
MINIO_BUCKET=

View File

@@ -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 05 rating - Log movies with a TMDB/OMDb ID and a 05 rating
- Immutable append-only viewing ledger (tracks re-watches) - 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 - RSS/Atom feed for public subscription
- JWT authentication via cookie (HTML) or Bearer token (REST API) - JWT authentication via cookie (HTML) or Bearer token (REST API)
- Zero JavaScript - Zero JavaScript
@@ -24,7 +24,7 @@ adapters/
sqlite — SQLite repository via sqlx sqlite — SQLite repository via sqlx
metadata — OMDb HTTP client metadata — OMDb HTTP client
poster-fetcher — downloads poster images 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 template-askama — Askama HTML rendering
rss — RSS/Atom feed generation rss — RSS/Atom feed generation
event-publisher — async event channel for background poster sync event-publisher — async event channel for background poster sync
@@ -34,12 +34,12 @@ adapters/
- Rust (stable, 2024 edition) - Rust (stable, 2024 edition)
- SQLite - 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) - An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
## Environment Variables ## 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 ```env
# Database # Database
@@ -47,25 +47,27 @@ DATABASE_URL=sqlite://movies.db
# Authentication # Authentication
JWT_SECRET=change-me JWT_SECRET=change-me
JWT_TTL_SECONDS=86400
# OMDb metadata # OMDb metadata
OMDB_API_KEY=your-key OMDB_API_KEY=your-key
# Poster storage (S3-compatible) # Poster storage — pick one backend:
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET=posters
MINIO_REGION=us-east-1
MINIO_ACCESS_KEY_ID=minioadmin
MINIO_SECRET_ACCESS_KEY=minioadmin
# Optional # Option A: local filesystem (zero deps)
ALLOW_REGISTRATION=false POSTER_STORAGE_BACKEND=local
POSTER_FETCH_TIMEOUT_SECONDS=10 POSTER_STORAGE_PATH=./posters
EVENT_CHANNEL_BUFFER=32
RUST_LOG=presentation=debug,tower_http=debug # 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 ## Run
```bash ```bash

View File

@@ -1,38 +1,86 @@
use anyhow::Context; use anyhow::Context;
use object_store::{aws::AmazonS3Builder, ObjectStore}; use object_store::{ObjectStore, aws::AmazonS3Builder, local::LocalFileSystem};
use std::sync::Arc; use std::sync::Arc;
pub struct StorageConfig { pub struct StorageConfig(Arc<dyn ObjectStore>);
endpoint: String,
access_key_id: String,
secret_access_key: String,
bucket: String,
region: String,
}
impl StorageConfig { impl StorageConfig {
pub fn from_env() -> anyhow::Result<Self> { pub fn from_env() -> anyhow::Result<Self> {
Ok(Self { let backend = std::env::var("POSTER_STORAGE_BACKEND")
endpoint: std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?, .context("POSTER_STORAGE_BACKEND required (valid values: s3, local)")?;
access_key_id: std::env::var("MINIO_ACCESS_KEY_ID")
.context("MINIO_ACCESS_KEY_ID required")?, let store: Arc<dyn ObjectStore> = match backend.as_str() {
secret_access_key: std::env::var("MINIO_SECRET_ACCESS_KEY") "s3" => build_s3_store(
.context("MINIO_SECRET_ACCESS_KEY required")?, &std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?,
bucket: std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?, &std::env::var("MINIO_ACCESS_KEY_ID").context("MINIO_ACCESS_KEY_ID required")?,
region: std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()), &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<Arc<dyn ObjectStore>> { pub fn build_store(self) -> Arc<dyn ObjectStore> {
let store = AmazonS3Builder::new() self.0
.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) fn build_s3_store(
.with_region(self.region) endpoint: &str,
.with_allow_http(true) access_key_id: &str,
.build() secret_access_key: &str,
.context("Failed to build S3/Minio store")?; bucket: &str,
Ok(Arc::new(store)) region: &str,
) -> anyhow::Result<Arc<dyn ObjectStore>> {
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<Arc<dyn ObjectStore>> {
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());
} }
} }

View File

@@ -25,8 +25,8 @@ impl PosterStorageAdapter {
Self { store } Self { store }
} }
pub fn from_config(config: StorageConfig) -> anyhow::Result<Self> { pub fn from_config(config: StorageConfig) -> Self {
Ok(Self::new(config.build_store()?)) Self::new(config.build_store())
} }
} }

View File

@@ -74,7 +74,7 @@ async fn wire_dependencies() -> anyhow::Result<AppState> {
let user_repository: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository::new(pool.clone())); let user_repository: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository::new(pool.clone()));
let metadata_client: Arc<dyn MetadataClient> = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); let metadata_client: Arc<dyn MetadataClient> = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key));
let poster_fetcher: Arc<dyn PosterFetcherClient> = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); let poster_fetcher: Arc<dyn PosterFetcherClient> = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
let poster_storage: Arc<dyn PosterStorage> = Arc::new(PosterStorageAdapter::from_config(storage_config)?); let poster_storage: Arc<dyn PosterStorage> = Arc::new(PosterStorageAdapter::from_config(storage_config));
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config)); let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher); let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);