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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.3
0.1.4
1 change: 1 addition & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

# Import models so Base.metadata has all table definitions registered
import lenny.core.models # noqa: F401
import lenny.core.cache # noqa: F401

# Alembic Config object — access to alembic.ini values
config = context.config
Expand Down
43 changes: 43 additions & 0 deletions alembic/versions/c6b7da6debc2_add_cache_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""add cache table

Revision ID: c6b7da6debc2
Revises: 001_baseline
Create Date: 2026-03-28 18:58:54.334094

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'c6b7da6debc2'
down_revision: Union[str, None] = '001_baseline'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('cache',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('scope', sa.String(length=64), nullable=False),
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('value', sa.String(length=1024), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
prefixes=['UNLOGGED']
)
op.create_index('idx_cache_expires', 'cache', ['expires_at'], unique=False)
op.create_index('idx_cache_scope_key_expires', 'cache', ['scope', 'key', 'expires_at'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_cache_scope_key_expires', table_name='cache')
op.drop_index('idx_cache_expires', table_name='cache')
op.drop_table('cache')
# ### end Alembic commands ###
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ services:
s3:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:1337/v1/api/opds || exit 1"]
test: ["CMD-SHELL", "curl -sf http://localhost:1337/v1/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
Expand Down
2 changes: 1 addition & 1 deletion docker/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ COPY ./docker/nginx/conf.d/lenny.conf /etc/nginx/conf.d/lenny.conf
COPY ./docker/utils/migrate.sh /app/docker/utils/migrate.sh
RUN chmod +x /app/docker/utils/migrate.sh

CMD ["sh", "-c", "sh /app/docker/utils/migrate.sh && python -m uvicorn lenny.app:app --host 0.0.0.0 --port 1337 --workers=${LENNY_WORKERS:-1} --log-level=${LENNY_LOG_LEVEL:-info} $([ \"${LENNY_PRODUCTION:-true}\" = \"false\" ] && echo --reload) & exec nginx"]
CMD ["sh", "-c", "sh /app/docker/utils/migrate.sh && python -m uvicorn lenny.app:app --host 0.0.0.0 --port 1337 --workers=${LENNY_WORKERS:-$([ \"${LENNY_PRODUCTION:-true}\" = \"false\" ] && echo 1 || echo 3)} --log-level=${LENNY_LOG_LEVEL:-info} $([ \"${LENNY_PRODUCTION:-true}\" = \"false\" ] && echo --reload) & exec nginx"]
26 changes: 25 additions & 1 deletion docker/configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ else
# Use environment variables if they are set, otherwise provide defaults or generate secure values
LENNY_HOST="localhost"
LENNY_PORT="${LENNY_PORT:-8080}"
LENNY_WORKERS="${LENNY_WORKERS:-1}"
LENNY_WORKERS="${LENNY_WORKERS:-3}"
LENNY_LOG_LEVEL="${LENNY_LOG_LEVEL:-debug}"
LENNY_PRODUCTION="${LENNY_PRODUCTION:-true}"
LENNY_SSL_CRT="${LENNY_SSL_CRT:-}"
Expand Down Expand Up @@ -96,3 +96,27 @@ NODE_ENV=$NODE_ENV

EOF
fi

# Install 'lenny' CLI command if not already available
LENNY_PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
if ! command -v lenny &>/dev/null; then
INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"
cat > "$INSTALL_DIR/lenny" <<SCRIPT
#!/bin/sh
make -C "$LENNY_PROJECT_DIR" "\$@"
SCRIPT
chmod +x "$INSTALL_DIR/lenny"

case ":$PATH:" in
*":$INSTALL_DIR:"*)
echo "[lenny] CLI installed. You can now use: lenny start, lenny stop, etc."
;;
*)
echo "[lenny] CLI installed to $INSTALL_DIR/lenny"
echo "[lenny] Add to PATH: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc"
;;
esac
else
echo "Skipping CLI install: 'lenny' command already available."
fi
30 changes: 30 additions & 0 deletions docker/nginx/conf.d/lenny.conf
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,37 @@ server {
error_log /var/log/nginx/error.log debug;
access_log /var/log/nginx/access.log;

# Sensitive routes: tighter rate limits
location ~ ^/v1/api/items/[^/]+/borrow {
limit_req zone=borrow burst=3 nodelay;
proxy_pass http://lenny_api:1337$request_uri;
proxy_set_header Host $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 /v1/api/oauth/authorize {
limit_req zone=oauth burst=3 nodelay;
proxy_pass http://lenny_api:1337/v1/api/oauth/authorize;
proxy_set_header Host $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 = /v1/api/upload {
limit_req zone=upload burst=2 nodelay;
proxy_pass http://lenny_api:1337/v1/api/upload;
proxy_set_header Host $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;
}

# General API: 30 req/min
location /v1/api {
limit_req zone=api burst=10 nodelay;
proxy_pass http://lenny_api:1337/v1/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
Expand Down
8 changes: 8 additions & 0 deletions docker/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ http {
sendfile on;
keepalive_timeout 65;
client_max_body_size 50M;

# Rate limiting: shared memory zones, no DB overhead.
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=borrow:5m rate=10r/m;
limit_req_zone $binary_remote_addr zone=oauth:5m rate=10r/m;
limit_req_zone $binary_remote_addr zone=upload:5m rate=5r/m;
limit_req_status 429;

include /etc/nginx/conf.d/*.conf;
}

Expand Down
2 changes: 1 addition & 1 deletion lenny/configs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
PROXY = os.environ.get('LENNY_PROXY', '')
HOST = os.environ.get('LENNY_HOST', 'localhost')
PORT = int(os.environ.get('LENNY_PORT', 8080))
WORKERS = int(os.environ.get('LENNY_WORKERS', 1))
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')
LOG_LEVEL = os.environ.get('LENNY_LOG_LEVEL', 'info')
Expand Down
27 changes: 8 additions & 19 deletions lenny/core/auth.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import hashlib
import hmac
import logging
import time
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.core.cache import Cache
from lenny.core.exceptions import RateLimitError

logging.basicConfig(
Expand Down Expand Up @@ -83,9 +82,6 @@ def verify_session_cookie(session, client_ip: str = None):

class OTP:

_attempts = {}
_send_attempts = {}

@classmethod
def generate(cls, email: str, issued_minute: int = None) -> str:
"""
Expand All @@ -112,11 +108,9 @@ def verify(cls, email: str, ip_address: str, otp: str) -> bool:
@classmethod
def is_send_rate_limited(cls, email: str) -> bool:
"""Limit OTP send requests: 5 emails per 5 minutes per email."""
now = time.time()
attempts = cls._send_attempts.get(email, [])
attempts = [ts for ts in attempts if now - ts < EMAIL_WINDOW_SECONDS]
cls._send_attempts[email] = attempts + [now]
return len(attempts) >= EMAIL_REQUEST_LIMIT
return Cache.is_throttled(
"otp:send", email, EMAIL_REQUEST_LIMIT, EMAIL_WINDOW_SECONDS
)

@classmethod
def issue(cls, email: str, ip_address: str) -> dict:
Expand All @@ -139,15 +133,10 @@ def redeem(cls, email: str, ip_address: str, otp: str) -> bool:

@classmethod
def is_rate_limited(cls, email: str) -> bool:
"""Updates attempts within timeframe for email and
returns True if the user is making too many attempts.
"""
now = time.time()
attempts = cls._attempts.get(email, [])
# Keep only recent attempts
attempts = [ts for ts in attempts if now - ts < ATTEMPT_WINDOW_SECONDS]
cls._attempts[email] = attempts + [now]
return len(attempts) >= ATTEMPT_LIMIT
"""Returns True if the user is making too many OTP verification attempts."""
return Cache.is_throttled(
"otp:verify", email, ATTEMPT_LIMIT, ATTEMPT_WINDOW_SECONDS
)

@classmethod
def authenticate(cls, email: str, otp: str, ip: str = None) -> Optional[str]:
Expand Down
118 changes: 118 additions & 0 deletions lenny/core/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
PostgreSQL-backed cache for Lenny.

Used for OTP email-based rate limiting across multiple Uvicorn workers.
IP-based rate limiting is handled by nginx (limit_req).

:copyright: (c) 2015 by AUTHORS
:license: see LICENSE for more details
"""

import logging
import random
from datetime import datetime, timedelta, timezone

from sqlalchemy import Column, String, BigInteger, DateTime, Index
from sqlalchemy.sql import func

from lenny.core.db import session as db, Base
from lenny.core.exceptions import DatabaseInsertError
from lenny import configs

logger = logging.getLogger(__name__)

PURGE_PROBABILITY = 0.01 # 1-in-100 chance per rate limit check

# UNLOGGED tables skip WAL for faster writes — ideal for ephemeral cache.
# SQLite (used in tests) doesn't support UNLOGGED, so we only apply it on PostgreSQL.
_cache_table_opts = {'prefixes': ['UNLOGGED']} if not configs.TESTING else {}


class CacheEntry(Base):
__tablename__ = 'cache'
__table_args__ = (
Index('idx_cache_scope_key_expires', 'scope', 'key', 'expires_at'),
Index('idx_cache_expires', 'expires_at'),
_cache_table_opts,
)

id = Column(BigInteger, primary_key=True)
scope = Column(String(64), nullable=False)
key = Column(String(255), nullable=False)
value = Column(String(1024), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)


class Cache:
"""PostgreSQL-backed cache with built-in rate limiting."""

@classmethod
def _record(cls, scope, key, ttl, value=None):
"""Insert a cache entry that expires after ttl seconds."""
try:
entry = CacheEntry(
scope=scope,
key=key,
value=value,
expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl),
)
db.add(entry)
db.commit()
return entry
except Exception as e:
db.rollback()
raise DatabaseInsertError(f"Failed to record cache entry: {str(e)}")

@classmethod
def _count(cls, scope, key):
"""Count unexpired entries for a given scope and key."""
try:
now = datetime.now(timezone.utc)
count = db.query(CacheEntry).filter(
CacheEntry.scope == scope,
CacheEntry.key == key,
CacheEntry.expires_at > now,
).count()
db.rollback()
return count
except Exception as e:
db.rollback()
logger.warning(f"Cache count failed: {str(e)}")
return 0

@classmethod
def is_throttled(cls, scope, key, limit, ttl):
"""Check if a key has exceeded its rate limit.

Counts existing unexpired entries, then records the current
attempt. Returns True if count >= limit (before recording).
Only records the attempt if not already throttled.
"""
current_count = cls._count(scope, key)

if current_count < limit:
try:
cls._record(scope, key, ttl)
except DatabaseInsertError:
pass

if random.random() < PURGE_PROBABILITY:
cls.purge()

return current_count >= limit

@classmethod
def purge(cls):
"""Delete all expired cache entries."""
try:
now = datetime.now(timezone.utc)
deleted = db.query(CacheEntry).filter(
CacheEntry.expires_at < now,
).delete()
db.commit()
if deleted:
logger.debug(f"Cache purge: removed {deleted} expired entries")
except Exception as e:
db.rollback()
logger.warning(f"Cache purge failed: {str(e)}")
6 changes: 6 additions & 0 deletions lenny/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def is_direct_auth_mode(auth_mode: Optional[str] = None, beta: bool = False) ->
return (auth_mode == "direct") or beta or configs.AUTH_MODE_DIRECT


# All IP-based rate limiting is handled by nginx (limit_req zones).
# OTP email-based rate limiting remains in auth.py via Cache.is_throttled.
router = APIRouter()

def requires_item_auth(do_function=None):
Expand Down Expand Up @@ -128,6 +130,10 @@ async def home(request: Request):
kwargs = {"request": request}
return request.app.templates.TemplateResponse("index.html", kwargs)

@router.get('/health', status_code=status.HTTP_200_OK)
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):
fields = fields.split(",") if fields else None
Expand Down
Loading