Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
- [FAQs](#faqs)
- [Tests](#tests)
- [Project Structure](#project-structure)
- [Admin Dashboard](#admin-dashboard)
- [Contributing](#contributing)
- [Pilot](#pilot)
- [Open Topics](#open-topics)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`)

---

Expand Down Expand Up @@ -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.
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.4
0.2.0
21 changes: 21 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ services:
context: .
dockerfile: docker/api/Dockerfile
container_name: lenny_api
restart: on-failure
ports:
- "${LENNY_PORT:-8080}:80"
depends_on:
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docker/configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions docker/lenny-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
60 changes: 49 additions & 11 deletions docker/nginx/conf.d/lenny.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion docker/utils/update/020_env_sync.sh
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lenny/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)

Expand Down
7 changes: 6 additions & 1 deletion lenny/configs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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']
14 changes: 11 additions & 3 deletions lenny/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading