131 lines
5.0 KiB
Python
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)
|