diff --git a/.env b/.env index 9d4f56e..84cc4b4 100644 --- a/.env +++ b/.env @@ -1,30 +1,20 @@ -# Environment variables for Mulita -# -# Set PHOTO_DIRS to the HOST path of your photo library. The compose file -# mounts this at /photos inside the container, and on first boot Mulita -# auto-creates a source root pointing at /photos so your library is -# scanned with zero further configuration. -# -# Examples: -# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures -# Network share: PHOTO_DIRS=/mnt/nas/photos -# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures +# Mulita / PhotoVault local environment. +# See .env.example for the full list of knobs and their docs. + +# REQUIRED — host path to your photo library. PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest -# Redis configuration -REDIS_URL=redis://localhost:6379 +# Ports — change if 3000 / 8001 collide with other services on the host. +FRONTEND_PORT=3000 +BACKEND_PORT=8001 +REDIS_PORT=6379 -# Database URL -DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db +# CORS — wildcard for local dev. Lock down for real deployments. +ALLOWED_ORIGINS=* -# Celery configuration -CELERY_BROKER_URL=redis://localhost:6379 -CELERY_RESULT_BACKEND=redis://localhost:6379 +# Logging + timezone. +LOG_LEVEL=INFO +TZ=UTC + +# Celery worker pool. CELERYD_CONCURRENCY=4 - -# API settings -API_HOST=0.0.0.0 -API_PORT=8000 - -# Frontend settings -VITE_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6418ed3 --- /dev/null +++ b/.env.example @@ -0,0 +1,83 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Mulita / PhotoVault — example environment file +# +# Copy this file to `.env` and adjust the values for your setup. Every key +# below has a sensible default in docker-compose.yml, so you only need to +# uncomment the ones you actually want to change. +# ───────────────────────────────────────────────────────────────────────────── + + +# ── REQUIRED ───────────────────────────────────────────────────────────────── + +# Host path to your photo library. The compose file mounts this at /photos +# inside the backend + worker containers. The backend creates a default +# source root pointing at /photos on first boot, so once this is set the +# library is scanned with zero further configuration. +# +# Examples: +# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures +# Network share: PHOTO_DIRS=/mnt/nas/photos +# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures +PHOTO_DIRS=./photos + + +# ── PORTS ──────────────────────────────────────────────────────────────────── + +# Host port the SPA is served on. Browse to http://:/. +FRONTEND_PORT=3000 + +# Host port for the backend API. Almost never needed directly — the frontend +# nginx proxies /api/ to the backend over the internal compose network. Kept +# exposed for debugging / curl. +BACKEND_PORT=8001 + +# Redis host port. Internal services reach Redis on its container name; this +# is just for local debugging. +REDIS_PORT=6379 + + +# ── CORS ───────────────────────────────────────────────────────────────────── + +# Comma-separated list of allowed origins for direct browser access to the +# backend. Same-origin requests through the nginx / vite proxy never trip +# CORS, so this only matters when something hits the backend port directly +# from a different origin (e.g. another machine, dev tools, a reverse proxy +# under a different hostname). +# +# Default "*" is permissive, fine for a single-user homelab. Lock it down in +# real deployments: +# ALLOWED_ORIGINS=https://photos.example.com +# ALLOWED_ORIGINS=https://photos.example.com,http://192.168.1.10:3000 +ALLOWED_ORIGINS=* + + +# ── LOGGING / TIMEZONE ─────────────────────────────────────────────────────── + +# Python log level for the backend and Celery worker. Bump to DEBUG when +# chasing scan / thumbnail issues. +LOG_LEVEL=INFO + +# Container timezone. Affects the timestamps in logs and the "added at" +# field on newly imported photos. Defaults to UTC. +# TZ=Europe/Berlin +# TZ=America/New_York +TZ=UTC + + +# ── WORKER CONCURRENCY ─────────────────────────────────────────────────────── + +# How many parallel Celery worker processes to spin up. Each one can run +# one scan / thumbnail / metadata job at a time. Bump on a beefy host with a +# big library; lower on a Pi. +CELERYD_CONCURRENCY=4 + + +# ── INTERNAL (rarely overridden) ───────────────────────────────────────────── + +# These point at the in-compose Redis and the bind-mounted SQLite db. Override +# only if you're running Mulita without docker-compose or against an external +# Redis. +# REDIS_URL=redis://redis:6379 +# CELERY_BROKER_URL=redis://redis:6379 +# CELERY_RESULT_BACKEND=redis://redis:6379 +# DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db diff --git a/README.md b/README.md index 4866a7f..16da932 100644 --- a/README.md +++ b/README.md @@ -43,19 +43,16 @@ git clone cd muleimage ``` -2. Set **one** environment variable in `.env` — the **host** directory - that contains your photo library. Whatever you point at will become - your library inside Mulita. +2. Copy the example env file and set **one** variable — the **host** + directory that contains your photo library. Whatever you point at + will become your library inside Mulita. ```bash - # macOS / Linux - PHOTO_DIRS=/Users/you/Pictures - - # or any folder - PHOTO_DIRS=/mnt/nas/photos - - # Windows (WSL) - PHOTO_DIRS=/mnt/c/Users/you/Pictures + cp .env.example .env + # then edit .env and set PHOTO_DIRS: + # macOS / Linux: PHOTO_DIRS=/Users/you/Pictures + # Network share: PHOTO_DIRS=/mnt/nas/photos + # Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures ``` 3. Start the stack: @@ -70,6 +67,38 @@ docker compose up -d You don't need to touch `mulita.yml` or the API to get started. +### Configuration knobs + +Everything is environment-driven. `PHOTO_DIRS` is the only required +value; the rest have sensible defaults documented in `.env.example`: + +| Variable | Default | Notes | +|----------------------|---------|----------------------------------------------------| +| `PHOTO_DIRS` | — | **Required.** Host path mounted at `/photos`. | +| `FRONTEND_PORT` | `3000` | SPA host port. Bump if `3000` is taken. | +| `BACKEND_PORT` | `8001` | Direct backend port (debug only — frontend uses internal nginx proxy). | +| `REDIS_PORT` | `6379` | Redis host port (internal services don't need it). | +| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. `https://photos.example.com`. | +| `LOG_LEVEL` | `INFO` | Backend + worker log level. `DEBUG` for chasing scan issues. | +| `TZ` | `UTC` | Container timezone. Affects log timestamps and "added at". | +| `CELERYD_CONCURRENCY`| `4` | Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. | + +### Accessing from another machine + +The frontend talks to the backend through its bundled nginx, which +proxies `/api/` to the backend on the internal compose network. That +means requests are always **same-origin** as the page, so accessing +Mulita from another host works without any CORS dance: + +``` +http://:3000 +``` + +If you want to put it behind a reverse proxy at e.g. +`https://photos.your.tld`, set `ALLOWED_ORIGINS` to that host so the +backend's direct port (`BACKEND_PORT`) also accepts cross-origin +requests if anything bypasses the proxy. + ### How libraries are managed Mulita is **config-driven**: the host directory you mount via diff --git a/backend/app/config.py b/backend/app/config.py index d7128b7..9e5803b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -70,7 +70,29 @@ class Settings(BaseSettings): # API settings api_host: str = Field(default="0.0.0.0", env="API_HOST") api_port: int = Field(default=8000, env="API_PORT") - + + # CORS — comma-separated list of allowed origins, or "*" for any. + # Same-origin requests (the normal case behind nginx / vite proxy) + # never trip CORS, so this is only for direct browser access from + # other origins (LAN IP, reverse proxy, dev tools). + allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS") + + # Logging — accepts standard python levels (DEBUG, INFO, WARNING, + # ERROR, CRITICAL). Bumped from INFO when chasing a problem. + log_level: str = Field(default="INFO", env="LOG_LEVEL") + + @property + def cors_origins(self) -> list[str]: + """Parse the ALLOWED_ORIGINS env var into a list. Accepts: + - "*" → wildcard (single-element list ["*"]) + - "http://a.com,http://b.com" → split + strip + Empty entries are dropped. + """ + raw = (self.allowed_origins or "").strip() + if not raw or raw == "*": + return ["*"] + return [o.strip() for o in raw.split(",") if o.strip()] + # App configuration from YAML _config: Optional[MulitaConfig] = None diff --git a/backend/app/main.py b/backend/app/main.py index 4bbb0c2..47b4499 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -64,14 +64,18 @@ app = FastAPI( # Configure CORS. The frontend normally talks to the backend through the # nginx (prod) or vite (dev) proxy, so requests are same-origin and never -# trip CORS. The wildcard here is a fallback for the rare case where a -# user / script hits the backend directly from a browser at some other -# origin (LAN IP, reverse proxy under a different host, etc.). This is a -# single-user homelab tool, so a permissive CORS policy is fine. +# trip CORS. ALLOWED_ORIGINS in .env controls the fallback for direct +# browser access from other origins (LAN IP, reverse proxy under a +# different host). Defaults to "*" since this is a single-user homelab +# tool; lock it down by setting e.g. ALLOWED_ORIGINS=https://photos.your.tld +# in production deployments. +_origins = settings.cors_origins app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=False, + allow_origins=_origins, + # Wildcard origins can't be combined with credentials per the CORS + # spec, so credentials get auto-disabled in that case. + allow_credentials=_origins != ["*"], allow_methods=["*"], allow_headers=["*"], ) diff --git a/docker-compose.yml b/docker-compose.yml index b6a89a0..db714f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,12 +2,14 @@ version: '3.8' services: frontend: - build: + build: context: ./frontend dockerfile: Dockerfile container_name: mulita-frontend ports: - - "3000:80" + # Host port is configurable via FRONTEND_PORT in .env so multiple + # instances / other services on the same host don't collide. + - "${FRONTEND_PORT:-3000}:80" depends_on: - backend networks: @@ -20,7 +22,10 @@ services: dockerfile: Dockerfile container_name: mulita-backend ports: - - "8001:8000" + # Direct backend access on the host is rarely needed (the frontend + # talks to it through the nginx /api proxy on the same network), + # but it's exposed for debugging / curl. Override with BACKEND_PORT. + - "${BACKEND_PORT:-8001}:8000" volumes: - ./mulita.yml:/app/config/mulita.yml:ro # The single host → container mount for your photo library. Set @@ -38,6 +43,9 @@ services: - CELERY_BROKER_URL=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379 - PHOTO_DIRS=${PHOTO_DIRS:-/photos} + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - TZ=${TZ:-UTC} depends_on: - redis networks: @@ -49,7 +57,7 @@ services: context: ./backend dockerfile: Dockerfile container_name: mulita-worker - command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4 + command: celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4} volumes: - ./mulita.yml:/app/config/mulita.yml:ro - ${PHOTO_DIRS:-./photos}:/photos:rw @@ -62,7 +70,9 @@ services: - CELERY_BROKER_URL=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379 - PHOTO_DIRS=${PHOTO_DIRS:-/photos} - - CELERYD_CONCURRENCY=4 + - CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - TZ=${TZ:-UTC} depends_on: - redis - backend @@ -73,8 +83,10 @@ services: redis: image: redis:7-alpine container_name: mulita-redis + # Host port exposed only for local debugging; the backend / worker + # reach Redis via the internal mulita-network on its container name. ports: - - "6379:6379" + - "${REDIS_PORT:-6379}:6379" volumes: - redis_data:/data networks: