Files

131 lines
5.0 KiB
Python

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)