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
12 changes: 3 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,7 @@ ALLOW_ORIGINS=*
ENVIRONMENT=development
LOG_LEVEL=info

# Database
POSTGRES_PORT=5432
POSTGRES_DB=moodleng
POSTGRES_USER=moodleng
POSTGRES_PASSWORD=moodleng_dev_password
REDIS_URL=redis://redis:6379/0

# Full database connection
DATABASE_URL=postgresql://moodleng:moodleng_dev_password@postgres:5432/moodleng

REDIS_URL=redis://redis:6379/0
# Rate limiting (default: 10 login attempts per minute per IP)
LOGIN_RATE_LIMIT=10/minute
25 changes: 0 additions & 25 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,6 @@ services:
networks:
- moodleng-network

postgres:
image: postgres:16-alpine
container_name: MoodleNG-Postgres
ports:
- "${POSTGRES_PORT:-5432}:5432"
environment:
- POSTGRES_DB=${POSTGRES_DB:-moodleng}
- POSTGRES_USER=${POSTGRES_USER:-moodleng}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-moodleng_dev_password}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-moodleng}"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
networks:
- moodleng-network

moodleware-api:
container_name: MoodlewareAPI
build:
Expand All @@ -53,12 +33,9 @@ services:
- SECRET_KEY=${SECRET_KEY:-cXWIu5Yj5P4TpHNcqwwVrPDqBQTstQF0A3_C_vjM2LQ}
- SESSION_MAX_AGE=${SESSION_MAX_AGE:-14400}
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
- DATABASE_URL=${DATABASE_URL:-postgresql://moodleng:moodleng_dev_password@postgres:5432/moodleng}
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- moodleng-network
Expand All @@ -70,5 +47,3 @@ networks:
volumes:
redis-data:
driver: local
postgres-data:
driver: local
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ uvicorn
colorlog
python-dotenv
itsdangerous
redis>=5.0.0
redis>=5.0.0
slowapi
40 changes: 27 additions & 13 deletions src/app.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import os
import logging
import uuid
import asyncio
from typing import Callable
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Security, Response
from dotenv import load_dotenv
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer
from redis.asyncio import Redis
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from .mw_utils.limiter import limiter
from .mw_utils import get_env_variable, load_config, create_handler
from .mw_utils.session import cleanup_expired_sessions, SESSION_MAX_AGE, init_redis, REDIS_URL
from .mw_utils.session import init_redis, REDIS_URL
from .routes.secure_auth import router as secure_auth_router
from .routes.files import router as files_router
from .routes.office_preview import router as office_preview_router
from .routes.files import router as files_router, init_http_client as init_files_client, close_http_client as close_files_client
from .routes.office_preview import router as office_preview_router, init_http_client as init_office_client, close_http_client as close_office_client

load_dotenv()

Expand All @@ -32,24 +35,31 @@ async def lifespan(app: FastAPI):
socket_connect_timeout=5,
socket_keepalive=True,
)

try:
# Test Redis connection
await redis_client.ping()
logger.info(f"Redis connected successfully: {REDIS_URL}")
init_redis(redis_client)
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
await redis_client.aclose()
raise

# Redis handles session expiration automatically via SETEX
# No cleanup task needed anymore
logger.info(f"Session storage initialized (Redis with automatic expiration)")


logger.info("Session storage initialized (Redis with automatic expiration)")

# Initialize shared HTTP clients
await init_files_client()
logger.info("File proxy HTTP client initialized")
await init_office_client()
logger.info("Office preview HTTP client initialized")

yield

# Close Redis connection

# Teardown
await close_files_client()
logger.info("File proxy HTTP client closed")
await close_office_client()
logger.info("Office preview HTTP client closed")
await redis_client.aclose()
logger.info("Redis connection closed")

Expand All @@ -62,6 +72,10 @@ async def lifespan(app: FastAPI):
lifespan=lifespan
)

app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)

# CORS configuration from env
_allow_origins_env = (get_env_variable("ALLOW_ORIGINS") or "").strip()

Expand Down
9 changes: 9 additions & 0 deletions src/mw_utils/limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from .env import get_env_variable

# Shared limiter instance – attached to app.state in app.py
limiter = Limiter(key_func=get_remote_address, default_limits=[])

# Configurable login rate limit; override via LOGIN_RATE_LIMIT env var
LOGIN_RATE_LIMIT: str = get_env_variable("LOGIN_RATE_LIMIT") or "10/minute"
41 changes: 26 additions & 15 deletions src/routes/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,36 @@

router = APIRouter(prefix="/files", tags=["files"])

# Persistent HTTP client for connection pooling and HTTP/2
_http_client: Optional[httpx.AsyncClient] = None


async def get_http_client() -> httpx.AsyncClient:
"""Get or create persistent HTTP client with connection pooling"""
async def init_http_client() -> None:
"""Initialize the persistent HTTP client. Called from app lifespan."""
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
http2=True, # Enable HTTP/2 for multiplexing
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=30.0,
)
_http_client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
http2=True,
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=30.0,
)
)


async def close_http_client() -> None:
"""Close the persistent HTTP client. Called from app lifespan."""
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None


def get_http_client() -> httpx.AsyncClient:
"""Return the lifespan-managed HTTP client."""
if _http_client is None:
raise RuntimeError("HTTP client not initialized")
return _http_client


Expand Down Expand Up @@ -96,8 +108,7 @@ async def proxy_file(
file_url = f"{moodle_url}{path}"

try:
# Get persistent HTTP client for connection pooling
client = await get_http_client()
client = get_http_client()

# Fetch the file from Moodle with authentication
file_content, moodle_headers = await fetch_file(
Expand Down
41 changes: 26 additions & 15 deletions src/routes/office_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,36 @@
TOKEN_PREFIX = "ot_token:"
TOKEN_EXPIRY_SECONDS = 60

# Persistent HTTP client for connection pooling (shared pattern from files.py)
_http_client: Optional[httpx.AsyncClient] = None


async def get_http_client() -> httpx.AsyncClient:
"""Get or create persistent HTTP client with connection pooling"""
async def init_http_client() -> None:
"""Initialize the persistent HTTP client. Called from app lifespan."""
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
http2=True,
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=30.0,
)
_http_client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
http2=True,
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=30.0,
)
)


async def close_http_client() -> None:
"""Close the persistent HTTP client. Called from app lifespan."""
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None


def get_http_client() -> httpx.AsyncClient:
"""Return the lifespan-managed HTTP client."""
if _http_client is None:
raise RuntimeError("HTTP client not initialized")
return _http_client


Expand Down Expand Up @@ -193,8 +205,7 @@ async def get_file_with_one_time_token(
moodle_url = moodle_url.rstrip('/')
file_url = f"{moodle_url}{file_path}"

# Fetch file from Moodle with persistent HTTP client
client = await get_http_client()
client = get_http_client()
file_content, moodle_headers = await fetch_file_from_moodle(
file_url,
moodle_token,
Expand Down
4 changes: 3 additions & 1 deletion src/routes/secure_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from ..mw_utils.env import get_env_variable
from ..mw_utils.http_client import DEFAULT_HEADERS
from ..mw_utils.limiter import limiter, LOGIN_RATE_LIMIT

logger = logging.getLogger("moodleware.secure_auth")
router = APIRouter(prefix="/secure", tags=["Secure Authentication"])
Expand Down Expand Up @@ -41,7 +42,8 @@ def _normalize_moodle_url(url: str) -> str:


@router.post("/login", response_model=LoginResponse)
async def secure_login(login_data: LoginRequest, response: Response):
@limiter.limit(LOGIN_RATE_LIMIT)
async def secure_login(request: Request, login_data: LoginRequest, response: Response):
moodle_url = _normalize_moodle_url(
login_data.moodle_url or get_env_variable("MOODLE_URL") or "https://moodle.example.com"
)
Expand Down
Loading