local file system

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

View File

@@ -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=
EVENT_CHANNEL_BUFFER=128
RUST_LOG=presentation=debug,tower_http=debug

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

View File

@@ -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<dyn ObjectStore>);
impl StorageConfig {
pub fn from_env() -> anyhow::Result<Self> {
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<dyn ObjectStore> = 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<Arc<dyn ObjectStore>> {
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<dyn ObjectStore> {
self.0
}
}
fn build_s3_store(
endpoint: &str,
access_key_id: &str,
secret_access_key: &str,
bucket: &str,
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 }
}
pub fn from_config(config: StorageConfig) -> anyhow::Result<Self> {
Ok(Self::new(config.build_store()?))
pub fn from_config(config: StorageConfig) -> Self {
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 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_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 password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);