import asyncio from contextlib import asynccontextmanager from typing import Optional from fastapi import FastAPI, Request, Response, HTTPException, status from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware from app import config from app.database import init_db from app.auth import ensure_admin_user, get_optional_user, require_user from app.dictionary import close_http_client from app.ai import warmup_ollama_model, close_ollama_client, translate_sentence_ai from app.reader import fetch_and_parse_rfc from app.listening import prewarm_all_listening_audio from app.routers import all_routers # Re-export models for backwards compatibility with tests and scripts from app.routers.auth import LoginRequest, ChangePasswordRequest from app.routers.cards import CardCreateRequest, CardUpdateRequest, BatchActionRequest from app.routers.srs import SrsReviewRequest from app.routers.reader import BookCreateRequest, BookProgressRequest, RfcFetchRequest, WikipediaFetchRequest from app.routers.dictionary import SentenceTranslateRequest from app.routers.cefr import CefrAnswerRequest, CefrResetRequest, CefrAddWordRequest from app.routers.grammar import GrammarAnswerRequest, GrammarCompleteRequest, GrammarHandwrittenRequest from app.routers.tasks import MineBooksRequest from app.routers.listening import ListeningCheckRequest from app.routers.lessons import SavePresetRequest, LessonDebriefRequest from app.routers.feedback import FeedbackCreateRequest, FeedbackStatusUpdateRequest @asynccontextmanager async def lifespan(app: FastAPI): init_db() ensure_admin_user() asyncio.create_task(warmup_ollama_model()) asyncio.create_task(prewarm_all_listening_audio()) yield await close_http_client() await close_ollama_client() app = FastAPI( title="Notes App", description="Personal Vocabulary & Language Notebook (notes.qpid.pro)", version="2.1.0", lifespan=lifespan, ) # GZip Compression Middleware (compresses responses >= 1000 bytes) app.add_middleware(GZipMiddleware, minimum_size=1000) # Mount static files app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static") # Security Headers Middleware @app.middleware("http") async def add_security_headers(request: Request, call_next): response = await call_next(request) response.headers["X-Frame-Options"] = "SAMEORIGIN" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "same-origin" return response # --------------------------------------------------------------------------- # PWA Static Endpoints # --------------------------------------------------------------------------- @app.get("/manifest.json", include_in_schema=False) async def manifest_endpoint(): manifest_path = config.BASE_DIR / "app" / "static" / "manifest.json" if manifest_path.exists(): return Response( content=manifest_path.read_text(encoding="utf-8"), media_type="application/manifest+json; charset=utf-8", headers={"Cache-Control": "public, max-age=86400"}, ) raise HTTPException(status_code=404, detail="Manifest not found") @app.get("/favicon.ico", include_in_schema=False) async def favicon_endpoint(): icon_path = config.BASE_DIR / "app" / "static" / "icons" / "icon-192.png" if icon_path.exists(): return Response( content=icon_path.read_bytes(), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, ) raise HTTPException(status_code=404, detail="Favicon not found") @app.get("/sw.js", include_in_schema=False) async def service_worker_endpoint(): sw_path = config.BASE_DIR / "app" / "static" / "sw.js" if sw_path.exists(): return Response( content=sw_path.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={ "Cache-Control": "no-cache, no-store, must-revalidate", "Service-Worker-Allowed": "/", }, ) raise HTTPException(status_code=404, detail="Service worker not found") # --------------------------------------------------------------------------- # HTML Entry Point # --------------------------------------------------------------------------- @app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse) async def index_page(request: Request): index_file = config.BASE_DIR / "app" / "static" / "index.html" if not index_file.exists(): raise HTTPException(status_code=404, detail="Index file not found") with open(index_file, "r", encoding="utf-8") as f: html_content = f.read() return HTMLResponse(content=html_content, status_code=200) # --------------------------------------------------------------------------- # Mount Modular API Routers # --------------------------------------------------------------------------- for router in all_routers: app.include_router(router)