Skip to content
Open
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
4 changes: 4 additions & 0 deletions lenny/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from lenny.routes import api
from lenny.configs import OPTIONS
from lenny import __version__ as VERSION
from lenny.core.ratelimit import init_rate_limiter

app = FastAPI(
title="Lenny API",
Expand All @@ -25,6 +26,9 @@
allow_headers=["*"],
)

# Initialize rate limiting
init_rate_limiter(app)

app.templates = Jinja2Templates(directory="lenny/templates")

app.include_router(api.router, prefix="/v1/api")
Expand Down
57 changes: 57 additions & 0 deletions lenny/core/ratelimit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@

"""
Rate limiting configuration for Lenny.
This module provides rate limiting settings that are compatible between
Nginx limit_req and slowapi, ensuring consistent TTL values.
:copyright: (c) 2015 by AUTHORS
:license: see LICENSE for more details
"""

import os
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

# Rate limit window in seconds - must be compatible between nginx and slowapi
# Using 60 seconds (1 minute) as a standard window
RATE_LIMIT_WINDOW = int(os.environ.get('RATE_LIMIT_WINDOW', 60))
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RATE_LIMIT_WINDOW is defined but never used anywhere in this module or imported elsewhere. Consider removing it or documenting its intended use case if it's meant for future Nginx configuration compatibility.

Suggested change
RATE_LIMIT_WINDOW = int(os.environ.get('RATE_LIMIT_WINDOW', 60))

Copilot uses AI. Check for mistakes.

def _parse_rate_limit(env_var: str, default: int) -> int:
"""
Parse rate limit from environment variable.
Supports both integer format (100) and string format ('100/minute').
Returns the integer count of requests.
"""
value = os.environ.get(env_var, str(default))
if '/' in str(value):
return int(value.split('/')[0])
return int(value)
Comment on lines +21 to +30
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _parse_rate_limit function lacks a return type annotation. Consider adding -> int to the function signature for consistency with type hints and improved code clarity.

Copilot uses AI. Check for mistakes.

# Supports both integer (100) and string ('100/minute') formats for backward compatibility
RATE_LIMIT_GENERAL_COUNT = _parse_rate_limit('RATE_LIMIT_GENERAL', 100)
RATE_LIMIT_LENIENT_COUNT = _parse_rate_limit('RATE_LIMIT_LENIENT', 300)
RATE_LIMIT_STRICT_COUNT = _parse_rate_limit('RATE_LIMIT_STRICT', 20)

RATE_LIMIT_GENERAL = f'{RATE_LIMIT_GENERAL_COUNT}/minute'
RATE_LIMIT_LENIENT = f'{RATE_LIMIT_LENIENT_COUNT}/minute'
RATE_LIMIT_STRICT = f'{RATE_LIMIT_STRICT_COUNT}/minute'

# Create the limiter instance
limiter = Limiter(
key_func=get_remote_address,
default_limits=[RATE_LIMIT_GENERAL],
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The limiter is configured with default_limits=[RATE_LIMIT_GENERAL], which applies the general rate limit (100/minute) to all endpoints by default. However, based on the PR description, OPDS endpoints and /items should have no rate limiting. Consider whether this default should be removed (set to []) to explicitly require rate limits on each endpoint, preventing unintended rate limiting on endpoints that should be unrestricted.

Suggested change
default_limits=[RATE_LIMIT_GENERAL],
default_limits=[],

Copilot uses AI. Check for mistakes.
headers_enabled=True,
)

def init_rate_limiter(app):
"""
Initialize rate limiting for the FastAPI app.
Args:
app: FastAPI application instance
"""
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

14 changes: 12 additions & 2 deletions lenny/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
)
from lenny.core.readium import ReadiumAPI
from lenny.core.models import Item
from lenny.core.ratelimit import limiter, RATE_LIMIT_GENERAL, RATE_LIMIT_LENIENT, RATE_LIMIT_STRICT
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RATE_LIMIT_LENIENT is imported but never used in this file. Consider removing it from the import statement to keep the code clean and avoid confusion.

Suggested change
from lenny.core.ratelimit import limiter, RATE_LIMIT_GENERAL, RATE_LIMIT_LENIENT, RATE_LIMIT_STRICT
from lenny.core.ratelimit import limiter, RATE_LIMIT_GENERAL, RATE_LIMIT_STRICT

Copilot uses AI. Check for mistakes.
from urllib.parse import quote

COOKIES_MAX_AGE = 604800 # 1 week
Expand Down Expand Up @@ -84,7 +85,7 @@ async def home(request: Request):
return request.app.templates.TemplateResponse("index.html", kwargs)

@router.get("/items")
async def get_items(fields: Optional[str]=None, offset: Optional[int]=None, limit: Optional[int]=None):
async def get_items(request: Request, fields: Optional[str]=None, offset: Optional[int]=None, limit: Optional[int]=None):
fields = fields.split(",") if fields else None
return LennyAPI.get_enriched_items(
fields=fields, offset=offset, limit=limit
Expand All @@ -110,6 +111,7 @@ async def get_opds_item(request: Request, book_id:int):

# Redirect to the Thorium Web Reader
@router.get("/items/{book_id}/read")
@limiter.limit(RATE_LIMIT_GENERAL)
@requires_item_auth()
async def redirect_reader(request: Request, book_id: str, format: str = "epub", session: Optional[str] = Cookie(None), item=None, email: str=''):
manifest_uri = LennyAPI.make_manifest_url(book_id)
Expand All @@ -119,12 +121,14 @@ async def redirect_reader(request: Request, book_id: str, format: str = "epub",
return RedirectResponse(url=reader_url, status_code=307)

@router.get("/items/{book_id}/readium/manifest.json")
@limiter.limit(RATE_LIMIT_GENERAL)
@requires_item_auth()
async def get_manifest(request: Request, book_id: str, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''):
return ReadiumAPI.get_manifest(book_id, format)

# Proxy all other readium requests
@router.get("/items/{book_id}/readium/{readium_path:path}")
@limiter.limit(RATE_LIMIT_GENERAL)
@requires_item_auth()
async def proxy_readium(request: Request, book_id: str, readium_path: str, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''):
readium_url = ReadiumAPI.make_url(book_id, format, readium_path)
Expand All @@ -135,6 +139,7 @@ async def proxy_readium(request: Request, book_id: str, readium_path: str, forma
return Response(content=r.content, media_type=content_type)

@router.post('/items/{book_id}/borrow')
@limiter.limit(RATE_LIMIT_GENERAL)
@requires_item_auth()
async def borrow_item(request: Request, book_id: int, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''):
"""
Expand All @@ -158,6 +163,7 @@ async def borrow_item(request: Request, book_id: int, format: str=".epub", sess
raise HTTPException(status_code=400, detail=str(e))

@router.post('/items/{book_id}/return', status_code=status.HTTP_200_OK)
@limiter.limit(RATE_LIMIT_GENERAL)
@requires_item_auth()
async def return_item(request: Request, book_id: int, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''):
"""
Expand All @@ -179,6 +185,7 @@ async def return_item(request: Request, book_id: int, format: str=".epub", sessi


@router.post('/upload', status_code=status.HTTP_200_OK)
@limiter.limit(RATE_LIMIT_STRICT)
async def upload(
request: Request,
openlibrary_edition: int = Form(
Expand Down Expand Up @@ -216,6 +223,7 @@ async def upload(
raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")

@router.post("/authenticate")
@limiter.limit(RATE_LIMIT_STRICT)
async def authenticate(request: Request, response: Response):
client_ip = request.client.host

Expand Down Expand Up @@ -261,6 +269,7 @@ async def authenticate(request: Request, response: Response):


@router.post('/items/borrowed', status_code=status.HTTP_200_OK)
@limiter.limit(RATE_LIMIT_GENERAL)
async def get_borrowed_items(request: Request, session : Optional[str] = Cookie(None)):
"""
Returns a list of active (not returned) borrowed items for the given patron's email.
Expand Down Expand Up @@ -294,7 +303,8 @@ async def get_borrowed_items(request: Request, session : Optional[str] = Cookie(


@router.get('/logout', status_code=status.HTTP_200_OK)
async def logout_page(response: Response):
@limiter.limit(RATE_LIMIT_GENERAL)
async def logout_page(request: Request, response: Response):
"""
Logs out the user and sends a logout confirmation JSON response.
"""
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ urllib3==2.4.0
uvicorn==0.32.0
watchfiles==1.0.5
itsdangerous==2.2.0
slowapi==0.1.9

git+https://github.com/ArchiveLabs/pyopds2.git
git+https://github.com/ArchiveLabs/pyopds2_openlibrary.git
Expand Down
Loading