diff --git a/README.md b/README.md index 26c31ee..113b9a0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ - [FAQs](#faqs) - [Tests](#tests) - [Project Structure](#project-structure) +- [Admin Dashboard](#admin-dashboard) - [Contributing](#contributing) - [Pilot](#pilot) - [Open Topics](#open-topics) @@ -89,6 +90,8 @@ To switch back to OAuth mode, simply visit the root feed without the parameter ( - **Readium Integration**: Secure, browser-based reading experience. - **Flexible Storage**: S3, Internet Archive, or local file support. - **Database-backed**: Uses PostgreSQL and SQLAlchemy. +- **Admin UI**: Secure admin dashboard served at `/admin`, isolated from public API access. +- **Encrypted/Unencrypted Item Filtering**: Filter catalog items by encryption status via API. --- ## OPDS 2.0 Feed @@ -118,6 +121,7 @@ To switch back to OAuth mode, simply visit the root feed without the parameter ( - `/v{1}/read` - `/v{1}/opds` - `/v{1}/stats` +- `/admin` — Admin UI (internal only, proxied to `lenny_admin:4000`) --- @@ -169,6 +173,22 @@ make url --- + +--- + +## Admin Dashboard + +Lenny includes a secure admin interface at `/admin` for managing the library. + +### Setup + +Change these variables in your `.env` or it will use system generated credentials: + +```env +ADMIN_USERNAME=your-username +ADMIN_PASSWORD=your-secure-password +``` + ## Adding Books encrypted or unencrypted To add a book to Lenny, you must provide an OpenLibrary Edition ID (OLID). Books without an OLID cannot be uploaded. @@ -183,7 +203,7 @@ https://openlibrary.org/books/add navigate to the above link and add all the details. -### Usage +### Usage using CLI ```sh make addbook olid=OL123456M filepath=/path/to/book.epub [encrypted=true] diff --git a/VERSION b/VERSION index 845639e..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.4 +0.2.0 diff --git a/compose.yaml b/compose.yaml index 4289c00..6200b70 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,7 @@ services: context: . dockerfile: docker/api/Dockerfile container_name: lenny_api + restart: on-failure ports: - "${LENNY_PORT:-8080}:80" depends_on: @@ -84,6 +85,26 @@ services: networks: - lenny_network + admin: + build: + context: . + dockerfile: docker/lenny-app/Dockerfile + args: + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-} + container_name: lenny_admin + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=4000 + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-} + - LENNY_INTERNAL_API_URL=http://lenny_api:1337/v1/api + - ADMIN_INTERNAL_SECRET=${ADMIN_INTERNAL_SECRET} + depends_on: + api: + condition: service_healthy + networks: + - lenny_network + readium: image: ghcr.io/readium/readium:0.6.3 container_name: lenny_readium diff --git a/docker/configure.sh b/docker/configure.sh index 89fbf73..f196235 100755 --- a/docker/configure.sh +++ b/docker/configure.sh @@ -23,6 +23,15 @@ else LENNY_SSL_CRT="${LENNY_SSL_CRT:-}" LENNY_SSL_KEY="${LENNY_SSL_KEY:-}" LENNY_SEED="${LENNY_SEED:-$(genpass 32)}" + ADMIN_USERNAME="${ADMIN_USERNAME:-admin}" + ADMIN_PASSWORD="${ADMIN_PASSWORD:-$(genpass 32)}" + ADMIN_INTERNAL_SECRET="${ADMIN_INTERNAL_SECRET:-$(genpass 32)}" + ADMIN_SALT="${ADMIN_SALT:-$(genpass 32)}" + # Public URL of the Lenny API as seen by the browser. + # Use a relative path (/v1/api) when the admin UI is served behind the same + # nginx, or set an absolute URL (https://library.example.com/v1/api) for + # external/custom-domain deployments. + NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-/v1/api}" OTP_SERVER="${OTP_SERVER:-https://openlibrary.org}" LENNY_LOAN_LIMIT="${LENNY_LOAN_LIMIT:-10}" @@ -57,6 +66,12 @@ LENNY_PRODUCTION=$LENNY_PRODUCTION LENNY_SSL_CRT=$LENNY_SSL_CRT LENNY_SSL_KEY=$LENNY_SSL_KEY OTP_SERVER=$OTP_SERVER +ADMIN_USERNAME=$ADMIN_USERNAME +ADMIN_PASSWORD=$ADMIN_PASSWORD +ADMIN_INTERNAL_SECRET=$ADMIN_INTERNAL_SECRET +ADMIN_SALT=$ADMIN_SALT +# Set to an absolute URL for custom-domain deployments, e.g. https://library.example.com/v1/api +NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL # Loan Limit LENNY_LOAN_LIMIT=$LENNY_LOAN_LIMIT diff --git a/docker/lenny-app/Dockerfile b/docker/lenny-app/Dockerfile new file mode 100644 index 0000000..19ee847 --- /dev/null +++ b/docker/lenny-app/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-slim AS base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +WORKDIR /app +RUN apt-get update && apt-get install -y git ca-certificates && rm -rf /var/lib/apt/lists/* +RUN corepack enable + + +RUN git clone https://github.com/ArchiveLabs/lenny-app . + +# Install all workspace deps (no cache mount — fully clean install) +RUN pnpm install --frozen-lockfile + +# Build only the web app +ARG NEXT_PUBLIC_API_URL= +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +RUN pnpm turbo run build --filter=web + +EXPOSE 4000 + +WORKDIR /app/apps/web +ENV PORT=4000 +CMD ["pnpm", "start"] diff --git a/docker/nginx/conf.d/lenny.conf b/docker/nginx/conf.d/lenny.conf index d855296..d956301 100644 --- a/docker/nginx/conf.d/lenny.conf +++ b/docker/nginx/conf.d/lenny.conf @@ -2,6 +2,9 @@ server { listen 80; server_name localhost; + # Use Docker's internal DNS so upstreams are resolved lazily (not at startup) + resolver 127.0.0.11 valid=10s ipv6=off; + # For better debugging error_log /var/log/nginx/error.log debug; access_log /var/log/nginx/access.log; @@ -34,6 +37,12 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Block admin API from external access — lenny-app calls FastAPI directly + # on the internal Docker network (lenny_api:1337), bypassing nginx entirely. + location /v1/api/admin { + return 403; + } + # General API: 30 req/min location /v1/api { limit_req zone=api burst=10 nodelay; @@ -55,24 +64,24 @@ server { location = /openapi.json { proxy_pass http://lenny_api:1337/openapi.json; } - + location /read { - proxy_pass http://lenny_reader:3000/read; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://lenny_reader:3000/read; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - sub_filter_types text/html text/css application/javascript application/json; + sub_filter_types text/css application/javascript application/json; sub_filter 'href="/' 'href="/read/'; sub_filter 'src="/' 'src="/read/'; - sub_filter 'url("/' 'url("/read/'; # For CSS background-images etc. - sub_filter_once off; # Apply filter multiple times + sub_filter 'url("/' 'url("/read/'; + sub_filter_once off; } location /read/ { - proxy_pass http://lenny_reader:3000$request_uri; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://lenny_reader:3000$request_uri; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } @@ -86,13 +95,42 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Admin UI — resolver + variable forces lazy DNS resolution so nginx starts + # even if lenny_admin isn't up yet. + location /admin { + set $admin_upstream lenny_admin:4000; + proxy_pass http://$admin_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /admin/ { + set $admin_upstream lenny_admin:4000; + proxy_pass http://$admin_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + location /_next/ { - proxy_pass http://lenny_reader:3000/_next/; # Important trailing slash for prefix stripping + proxy_pass http://lenny_reader:3000/_next/; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + location ~ ^/__nextjs_ { proxy_pass http://lenny_reader:3000$request_uri; proxy_set_header Host $http_host; diff --git a/docker/utils/update/020_env_sync.sh b/docker/utils/update/020_env_sync.sh index 225feb6..ebf1a3a 100755 --- a/docker/utils/update/020_env_sync.sh +++ b/docker/utils/update/020_env_sync.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +genpass() { + local len=${1:-32} + dd if=/dev/urandom bs=1 count=$((len * 2)) 2>/dev/null | base64 | tr -dc 'A-Za-z0-9' | head -c "$len" +} + # Sync new environment variables from configure.sh into .env and reader.env # # Safety guarantees: @@ -86,7 +91,7 @@ sync_env_file() { # If the value is a shell variable reference ($VAR or ${VAR...}), # resolve the default from its assignment in configure.sh. - # Generated values (passwords/keys using $(genpass)) stay empty. + # Generated values (passwords/keys using $(genpass)) are auto-generated. value=$(echo "$value" | sed 's/^[[:space:]]*//') if echo "$value" | grep -qE '^\$'; then local ref_var @@ -96,6 +101,10 @@ sync_env_file() { | sed "s/.*:-\(.*\)}\".*/\1/" | head -1) || true if [ -n "$default" ] && ! echo "$default" | grep -qE '^\$\('; then value="$default" + elif echo "$default" | grep -qE '^\$\(genpass'; then + local genpass_len + genpass_len=$(echo "$default" | grep -oE '[0-9]+' | head -1) + value=$(genpass "${genpass_len:-32}") else value="" fi diff --git a/lenny/app.py b/lenny/app.py index d12994c..c882db4 100755 --- a/lenny/app.py +++ b/lenny/app.py @@ -20,7 +20,7 @@ CORSMiddleware, allow_origin_regex=".*", allow_credentials=True, - allow_methods=["GET", "OPTIONS"], + allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["Authorization", "Content-Type"], ) diff --git a/lenny/configs/__init__.py b/lenny/configs/__init__.py index b5495ee..475331f 100644 --- a/lenny/configs/__init__.py +++ b/lenny/configs/__init__.py @@ -21,6 +21,10 @@ WORKERS = int(os.environ.get('LENNY_WORKERS', 1 if TESTING else 3)) DEBUG = bool(int(os.environ.get('LENNY_DEBUG', 0))) SEED = os.environ.get('LENNY_SEED') +ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME', 'admin') +ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') +ADMIN_INTERNAL_SECRET = os.environ.get('ADMIN_INTERNAL_SECRET') +ADMIN_SALT = os.environ.get('ADMIN_SALT') LOG_LEVEL = os.environ.get('LENNY_LOG_LEVEL', 'info') SSL_CRT = os.environ.get('LENNY_SSL_CRT') SSL_KEY = os.environ.get('LENNY_SSL_KEY') @@ -69,4 +73,5 @@ 'secure': os.environ.get('S3_SECURE', 'false').lower() == 'true', } -__all__ = ['SCHEME', 'HOST', 'PORT', 'DEBUG', 'OPTIONS', 'DB_URI', 'DB_CONFIG','S3_CONFIG', 'TESTING'] +__all__ = ['SCHEME', 'HOST', 'PORT', 'DEBUG', 'OPTIONS', 'DB_URI', 'DB_CONFIG', 'S3_CONFIG', 'TESTING', + 'ADMIN_USERNAME', 'ADMIN_PASSWORD', 'ADMIN_INTERNAL_SECRET', 'ADMIN_SALT'] diff --git a/lenny/core/api.py b/lenny/core/api.py index f3fc692..5f8d21c 100644 --- a/lenny/core/api.py +++ b/lenny/core/api.py @@ -3,6 +3,7 @@ from fastapi import UploadFile, Request from botocore.exceptions import ClientError import socket +import ipaddress from pyopds2_lenny import LennyDataProvider, LennyDataRecord, build_post_borrow_publication from pyopds2 import Catalog, Metadata from pyopds2.models import Link, Navigation @@ -121,14 +122,14 @@ def _enrich_items(cls, items, fields=None, limit=None): return {} @classmethod - def get_enriched_items(cls, olid=None, fields=None, offset=None, limit=None): + def get_enriched_items(cls, olid=None, fields=None, offset=None, limit=None, encrypted=None): """Returns a dict whose keys are int `olid` Open Library - edition IDs and whose values are OpenLibraryRecords wwith an + edition IDs and whose values are OpenLibraryRecords with an additional `lenny` field containing Lenny's record for this item in the LennyDB """ limit = limit or cls.DEFAULT_LIMIT - items = [Item.exists(olid)] if olid else Item.get_many(offset=offset, limit=limit) + items = [Item.exists(olid)] if olid else Item.get_many(offset=offset, limit=limit, encrypted=encrypted) return cls._enrich_items(items, fields=fields) @classmethod @@ -313,6 +314,13 @@ def is_allowed_uploader(cls, client_ip: str) -> bool: if client_ip in ("127.0.0.1", "::1"): return True + # Allow Docker internal network (admin container proxies uploads server-side) + try: + if ipaddress.ip_address(client_ip).is_private: + return True + except ValueError: + pass + if host := cls._resolve_ip_to_hostname(client_ip): for allowed_host in ["localhost", "openlibrary.press"]: if host == allowed_host or host.endswith(allowed_host): diff --git a/lenny/core/auth.py b/lenny/core/auth.py index a59b55f..52507fa 100644 --- a/lenny/core/auth.py +++ b/lenny/core/auth.py @@ -1,10 +1,11 @@ import hashlib +import hmac import logging import httpx from datetime import datetime, timedelta from typing import Optional from itsdangerous import URLSafeTimedSerializer, BadSignature -from lenny.configs import SEED, OTP_SERVER +from lenny.configs import SEED, OTP_SERVER, ADMIN_USERNAME, ADMIN_PASSWORD, ADMIN_INTERNAL_SECRET, ADMIN_SALT from lenny.core.cache import Cache from lenny.core.exceptions import RateLimitError @@ -19,6 +20,43 @@ logging.getLogger("hpack").setLevel(logging.WARNING) logging.getLogger("multipart").setLevel(logging.WARNING) +ADMIN_TOKEN_TTL = 86400 # 24 hours +ADMIN_SERIALIZER = None # Initialized lazily + +def _get_admin_serializer(): + global ADMIN_SERIALIZER + if ADMIN_SERIALIZER is None: + ADMIN_SERIALIZER = URLSafeTimedSerializer(SEED, salt=ADMIN_SALT) + return ADMIN_SERIALIZER + +def verify_admin_internal_secret(secret: str) -> bool: + """Constant-time comparison to validate the internal shared secret.""" + if not ADMIN_INTERNAL_SECRET or not secret: + return False + return hmac.compare_digest(ADMIN_INTERNAL_SECRET, secret) + +def authenticate_admin(username: str, password: str) -> Optional[str]: + """Validates admin username + password and returns a signed token on success.""" + if not ADMIN_USERNAME or not ADMIN_PASSWORD: + return None + username_ok = hmac.compare_digest(ADMIN_USERNAME, username) + password_ok = hmac.compare_digest(ADMIN_PASSWORD, password) + if not (username_ok and password_ok): + return None + serializer = _get_admin_serializer() + return serializer.dumps({"admin": True}) + +def verify_admin_token(token: str) -> bool: + """Validates a signed admin token. Returns True if valid and not expired.""" + try: + if not token: + return False + serializer = _get_admin_serializer() + data = serializer.loads(token, max_age=ADMIN_TOKEN_TTL) + return isinstance(data, dict) and data.get("admin") is True + except BadSignature: + return False + ATTEMPT_LIMIT = 5 ATTEMPT_WINDOW_SECONDS = 60 SERIALIZER = None # Will be initialized lazily diff --git a/lenny/core/models.py b/lenny/core/models.py index bd80ce2..12f7af0 100644 --- a/lenny/core/models.py +++ b/lenny/core/models.py @@ -101,6 +101,13 @@ def is_printdisabled(self): """Always print disabled.""" return True + @classmethod + def get_many(cls, offset=None, limit=None, encrypted=None): + q = db.query(cls) + if encrypted is not None: + q = q.filter(cls.encrypted == encrypted) + return q.offset(offset).limit(limit).all() + @classmethod def exists(cls, olid): return db.query(Item).filter(Item.openlibrary_edition == olid).first() diff --git a/lenny/routes/api.py b/lenny/routes/api.py index ab0fedb..0293b6c 100644 --- a/lenny/routes/api.py +++ b/lenny/routes/api.py @@ -135,10 +135,10 @@ async def health(): return {"status": "ok"} @router.get("/items") -async def get_items(fields: Optional[str]=None, offset: Optional[int]=None, limit: Optional[int]=None): +async def get_items(fields: Optional[str]=None, offset: Optional[int]=None, limit: Optional[int]=None, encrypted: Optional[bool]=None): fields = fields.split(",") if fields else None return LennyAPI.get_enriched_items( - fields=fields, offset=offset, limit=limit + fields=fields, offset=offset, limit=limit, encrypted=encrypted ) @router.get("/opds") @@ -362,7 +362,7 @@ async def upload( content="File uploaded successfully." ) except UploaderNotAllowedError as e: - raise HTTPException(status_code=503, details=str(e)) + raise HTTPException(status_code=503, detail=str(e)) except ItemExistsError as e: raise HTTPException(status_code=409, detail=str(e)) except InvalidFileError as e: @@ -542,4 +542,40 @@ async def oauth_authorize( context["error"] = "Failed to issue OTP. Please try again." return request.app.templates.TemplateResponse("otp_issue.html", context) - return request.app.templates.TemplateResponse("otp_issue.html", context) \ No newline at end of file + return request.app.templates.TemplateResponse("otp_issue.html", context) + +@router.post("/admin/auth", status_code=status.HTTP_200_OK) +async def admin_auth(request: Request, body: dict = Body(...)): + """ + Validates the admin key and internal secret, returns a signed token. + Called server-side from lenny-app; never exposed through nginx. + """ + internal_secret = request.headers.get("X-Admin-Internal-Secret", "") + if not auth.verify_admin_internal_secret(internal_secret): + raise HTTPException(status_code=403, detail="Forbidden") + + username = body.get("username", "") + password = body.get("password", "") + token = auth.authenticate_admin(username, password) + if not token: + raise HTTPException(status_code=401, detail="Invalid admin key") + + return JSONResponse({"token": token}) + + +@router.get("/admin/verify", status_code=status.HTTP_200_OK) +async def admin_verify(request: Request): + """ + Verifies a signed admin token passed as a Bearer token. + Called server-side from lenny-app middleware; never exposed through nginx. + """ + internal_secret = request.headers.get("X-Admin-Internal-Secret", "") + if not auth.verify_admin_internal_secret(internal_secret): + raise HTTPException(status_code=403, detail="Forbidden") + + authorization = request.headers.get("Authorization", "") + token = authorization.removeprefix("Bearer ").strip() + if not auth.verify_admin_token(token): + raise HTTPException(status_code=401, detail="Invalid or expired token") + + return JSONResponse({"valid": True}) \ No newline at end of file