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
34 changes: 34 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Moodle Configuration
MOODLE_URL=https://moodle.school.edu

# Port Configuration
API_PORT=8000
FRONTEND_PORT=80

# API URL for Frontend
# This is the URL the frontend will use to make API requests
# For local development: http://localhost:8000
# For production: https://api.yourdomain.com
API_BASE_URL=http://localhost:8000

# Security
SECRET_KEY=CHANGE_ME_IN_PRODUCTION
SESSION_MAX_AGE=14400

# CORS Configuration
ALLOW_ORIGINS=*

# Environment
ENVIRONMENT=development
LOG_LEVEL=info

# Database
POSTGRES_PORT=5432
POSTGRES_DB=moodleng
POSTGRES_USER=moodleng
POSTGRES_PASSWORD=moodleng_dev_password

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

REDIS_URL=redis://redis:6379/0
20 changes: 0 additions & 20 deletions .env.template

This file was deleted.

24 changes: 19 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
FROM python:3.13.5-slim
# Stage 1: Builder - Install dependencies
FROM python:3.13-alpine AS builder

WORKDIR /app

ENV PYTHONUNBUFFERED=1
# Install build dependencies needed to compile Python packages
RUN apk add --no-cache gcc musl-dev

# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --user -r requirements.txt

# Stage 2: Runtime - Copy only what's needed
FROM python:3.13-alpine

WORKDIR /app

# Copy Python packages from builder
COPY --from=builder /root/.local /root/.local

# Copy application code
COPY . .

# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["python", "asgi.py"]
75 changes: 68 additions & 7 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,74 @@
# This compose file is meant for development purposes.
name: MoodlewareAPI

services:
moodle-api:
redis:
image: redis:alpine
container_name: MoodleNG-Redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
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: .
build:
context: .
dockerfile: Dockerfile
ports:
- ${PORT-8000}:8000
- "${API_PORT:-8000}:8000"
environment:
- MOODLE_URL=${MOODLE_URL}
- ALLOW_ORIGINS=${ALLOW_ORIGINS}
- LOG_LEVEL=${LOG_LEVEL}
- MOODLE_URL=${MOODLE_URL:-https://moodle.school.edu}
- ALLOW_ORIGINS=${ALLOW_ORIGINS:-*}
- ENVIRONMENT=${ENVIRONMENT:-development}
- LOG_LEVEL=${LOG_LEVEL:-info}
- 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

networks:
moodleng-network:
driver: bridge

volumes:
redis-data:
driver: local
postgres-data:
driver: local
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
fastapi
httpx
httpx[http2]
pydantic
uvicorn
colorlog
python-dotenv
itsdangerous
itsdangerous
redis>=5.0.0
90 changes: 76 additions & 14 deletions src/app.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,98 @@
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 .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 .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

load_dotenv()

# Configure logging level (default to INFO)
_log_level_name = (get_env_variable("LOG_LEVEL") or "info").upper()
_log_level = getattr(logging, _log_level_name, logging.INFO)
logging.basicConfig(level=_log_level)
logger = logging.getLogger("moodleware")

@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize Redis connection
redis_client = Redis.from_url(
REDIS_URL,
encoding="utf-8",
decode_responses=True,
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)")

yield

# Close Redis connection
await redis_client.aclose()
logger.info("Redis connection closed")

app = FastAPI(
title="MoodlewareAPI",
description="A FastAPI application to wrap Moodle API functions into individual endpoints.",
version="0.1.0",
docs_url="/",
redoc_url=None
redoc_url=None,
lifespan=lifespan
)

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

# For wildcard CORS with credentials, we need to use regex to match all origins
if _allow_origins_env == "" or _allow_origins_env == "*":
_allow_origins = ["*"]
_allow_credentials = False # '*' cannot be used with credentials per CORS spec
# Use regex to allow any origin (required for credentials with wildcard)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r".*", # Allow any origin
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-Id"],
max_age=86400, # 24 hours
)
logger.info("CORS: Allowing all origins with credentials (regex pattern)")
else:
# Specific origins configured
_allow_origins = [o.strip() for o in _allow_origins_env.split(",") if o.strip()]
_allow_credentials = True

app.add_middleware(
CORSMiddleware,
allow_origins=_allow_origins,
allow_credentials=_allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)

app.add_middleware(
CORSMiddleware,
allow_origins=_allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-Id"],
max_age=86400,
)

logger.info(f"CORS: Allowing specific origins: {_allow_origins}")

# Request ID middleware
@app.middleware("http")
Expand Down Expand Up @@ -76,7 +129,16 @@ async def add_request_id(request: Request, call_next: Callable):
dependencies=deps,
)

# Register secure authentication routes
app.include_router(secure_auth_router)

# Register file proxy routes
app.include_router(files_router)

# Register office preview one-time token routes
app.include_router(office_preview_router)

# Health check
@app.get("/healthz", tags=["meta"])
@app.post("/healthz", tags=["meta"])
async def healthz():
return {"status": "ok"}
2 changes: 1 addition & 1 deletion src/config/_login_token-php/Authentication/auth.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get Moodle token for API calls.",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get assignments from specified courses",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get assignment submission status",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get assignment submissions",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get action events (assignments, quizzes) by course",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get calendar events",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get completion status for activities in a course",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get course completion status for a user",
"query_params": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get Moodle site information & user information",
"query_params": [],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get course categories",
"query_params": [],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"method": "GET",
"method": "POST",
"description": "Get course contents (sections and activities)",
"query_params": [
{
Expand Down
Loading
Loading