diff --git a/lenny/app.py b/lenny/app.py index 12ec6ae..fc1a532 100755 --- a/lenny/app.py +++ b/lenny/app.py @@ -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", @@ -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") diff --git a/lenny/core/ratelimit.py b/lenny/core/ratelimit.py new file mode 100644 index 0000000..e49db90 --- /dev/null +++ b/lenny/core/ratelimit.py @@ -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)) + +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) + +# 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], + 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) + diff --git a/lenny/routes/api.py b/lenny/routes/api.py index 43458dc..b67f826 100644 --- a/lenny/routes/api.py +++ b/lenny/routes/api.py @@ -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 from urllib.parse import quote COOKIES_MAX_AGE = 604800 # 1 week @@ -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 @@ -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) @@ -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) @@ -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=''): """ @@ -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=''): """ @@ -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( @@ -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 @@ -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. @@ -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. """ diff --git a/requirements.txt b/requirements.txt index 472d67d..5be3597 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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