local file system
This commit is contained in:
41
.env.example
41
.env.example
@@ -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=
|
|
||||||
|
|||||||
34
README.md
34
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
|
- Log movies with a TMDB/OMDb ID and a 0–5 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
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user