import os import urllib.parse from contextlib import asynccontextmanager from typing import Optional, List from fastapi import FastAPI, Depends, HTTPException, Request, Response, status, Query from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field import httpx from app import config from app.database import init_db, get_db from app.auth import ( ensure_admin_user, authenticate_user, create_session, get_user_from_session, delete_session, check_login_rate_limit, record_login_failure, record_login_success ) from app.dictionary import lookup_term, USER_AGENT @asynccontextmanager async def lifespan(app: FastAPI): init_db() ensure_admin_user() yield app = FastAPI( title="Notes App", description="Personal Vocabulary & Language Notebook (notes.qpid.pro)", version="1.0.0", lifespan=lifespan ) # 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 # Dependency: get authenticated user or None def get_optional_user(request: Request) -> Optional[dict]: token = request.cookies.get(config.SESSION_COOKIE_NAME) if not token: # Check Authorization header bearer token fallback auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:].strip() if token: return get_user_from_session(token) return None # Dependency: require authenticated user def require_user(request: Request) -> dict: user = get_optional_user(request) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) return user # Models class LoginRequest(BaseModel): username: str = Field(..., min_length=1) password: str = Field(..., min_length=1) class CardCreateRequest(BaseModel): term: str = Field(..., min_length=1) translation: str = Field(..., min_length=1) context_sentence: Optional[str] = "" phonetic: Optional[str] = "" audio_url: Optional[str] = "" part_of_speech: Optional[str] = "" definition_en: Optional[str] = "" tags: Optional[str] = "general" notes: Optional[str] = "" class CardUpdateRequest(BaseModel): term: Optional[str] = None translation: Optional[str] = None context_sentence: Optional[str] = None phonetic: Optional[str] = None audio_url: Optional[str] = None part_of_speech: Optional[str] = None definition_en: Optional[str] = None tags: Optional[str] = None notes: Optional[str] = None # Routes @app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse) async def serve_index(request: Request): index_path = config.BASE_DIR / "app" / "static" / "index.html" if not index_path.is_file(): raise HTTPException(status_code=404, detail="Index file not found") with open(index_path, "r", encoding="utf-8") as f: content = f.read() return HTMLResponse(content) @app.post("/api/login") async def api_login(req: LoginRequest, request: Request, response: Response): client_ip = request.client.host if request.client else "unknown" if not check_login_rate_limit(client_ip): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many failed login attempts. Please wait 1 minute." ) user = authenticate_user(req.username, req.password) if not user: record_login_failure(client_ip) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password" ) record_login_success(client_ip) user_agent = request.headers.get("User-Agent", "")[:255] session_token = create_session(user["id"], user_agent, client_ip) # Set secure HttpOnly cookie response.set_cookie( key=config.SESSION_COOKIE_NAME, value=session_token, max_age=config.SESSION_EXPIRE_DAYS * 86400, httponly=True, samesite="lax", secure=False # Nginx handles SSL termination ) return {"status": "ok", "user": user} @app.post("/api/logout") async def api_logout(request: Request, response: Response): token = request.cookies.get(config.SESSION_COOKIE_NAME) if token: delete_session(token) response.delete_cookie(config.SESSION_COOKIE_NAME) return {"status": "ok", "message": "Logged out successfully"} @app.get("/api/me") async def api_me(user: dict = Depends(require_user)): return {"user": user} @app.get("/api/lookup") async def api_lookup(q: str = Query(..., min_length=1), user: dict = Depends(require_user)): result = await lookup_term(q) return result @app.api_route("/api/audio", methods=["GET", "HEAD"]) async def api_audio_proxy(file: str = Query(..., min_length=1)): """Proxies Wikimedia audio file with required User-Agent.""" cleaned_file = urllib.parse.unquote(file).strip() # Basic safety check if ".." in cleaned_file or "/" in cleaned_file or "\\" in cleaned_file: raise HTTPException(status_code=400, detail="Invalid filename") target_url = f"https://commons.wikimedia.org/wiki/Special:FilePath/{urllib.parse.quote(cleaned_file)}" async def stream_audio(): headers = {"User-Agent": USER_AGENT} async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client: async with client.stream("GET", target_url, headers=headers) as resp: if resp.status_code != 200: yield b"" return async for chunk in resp.aiter_bytes(): yield chunk # Determine mime-type based on extension ext = cleaned_file.lower().split(".")[-1] mime_map = { "ogg": "audio/ogg", "oga": "audio/ogg", "mp3": "audio/mpeg", "wav": "audio/wav", "flac": "audio/flac" } media_type = mime_map.get(ext, "application/octet-stream") return StreamingResponse( stream_audio(), media_type=media_type, headers={"Cache-Control": "public, max-age=86400"} ) @app.get("/api/cards") async def api_get_cards( search: Optional[str] = None, tag: Optional[str] = None, limit: int = 100, offset: int = 0, user: dict = Depends(require_user) ): with get_db() as conn: query = """ SELECT id, term, translation, context_sentence, phonetic, audio_url, part_of_speech, definition_en, tags, notes, created_at, updated_at FROM vocabulary_cards WHERE user_id = ? """ params = [user["id"]] if search: query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)" s = f"%{search.strip()}%" params.extend([s, s, s, s]) if tag: query += " AND (',' || tags || ',') LIKE ?" params.append(f"%,{tag.strip()}%,") query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([min(limit, 500), max(offset, 0)]) rows = conn.execute(query, params).fetchall() # Count total count_query = "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?" count_params = [user["id"]] if search: count_query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)" s = f"%{search.strip()}%" count_params.extend([s, s, s, s]) if tag: count_query += " AND (',' || tags || ',') LIKE ?" count_params.append(f"%,{tag.strip()}%,") total = conn.execute(count_query, count_params).fetchone()[0] cards = [dict(r) for r in rows] return {"total": total, "cards": cards} @app.post("/api/cards") async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)): with get_db() as conn: cursor = conn.execute( """ INSERT INTO vocabulary_cards ( user_id, term, translation, context_sentence, phonetic, audio_url, part_of_speech, definition_en, tags, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user["id"], card.term.strip(), card.translation.strip(), card.context_sentence.strip() if card.context_sentence else "", card.phonetic.strip() if card.phonetic else "", card.audio_url.strip() if card.audio_url else "", card.part_of_speech.strip() if card.part_of_speech else "", card.definition_en.strip() if card.definition_en else "", card.tags.strip() if card.tags else "general", card.notes.strip() if card.notes else "" ) ) new_id = cursor.lastrowid row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone() return dict(row) @app.put("/api/cards/{card_id}") async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)): with get_db() as conn: row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?", (card_id, user["id"])).fetchone() if not row: raise HTTPException(status_code=404, detail="Card not found") current = dict(row) term = update.term.strip() if update.term is not None else current["term"] translation = update.translation.strip() if update.translation is not None else current["translation"] context_sentence = update.context_sentence.strip() if update.context_sentence is not None else current["context_sentence"] phonetic = update.phonetic.strip() if update.phonetic is not None else current["phonetic"] audio_url = update.audio_url.strip() if update.audio_url is not None else current["audio_url"] part_of_speech = update.part_of_speech.strip() if update.part_of_speech is not None else current["part_of_speech"] definition_en = update.definition_en.strip() if update.definition_en is not None else current["definition_en"] tags = update.tags.strip() if update.tags is not None else current["tags"] notes = update.notes.strip() if update.notes is not None else current["notes"] conn.execute( """ UPDATE vocabulary_cards SET term = ?, translation = ?, context_sentence = ?, phonetic = ?, audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? """, (term, translation, context_sentence, phonetic, audio_url, part_of_speech, definition_en, tags, notes, card_id, user["id"]) ) updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone() return dict(updated_row) @app.delete("/api/cards/{card_id}") async def api_delete_card(card_id: int, user: dict = Depends(require_user)): with get_db() as conn: cursor = conn.execute( "DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?", (card_id, user["id"]) ) if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Card not found") return {"status": "ok", "deleted_id": card_id} @app.get("/api/tags") async def api_get_tags(user: dict = Depends(require_user)): with get_db() as conn: rows = conn.execute( "SELECT tags FROM vocabulary_cards WHERE user_id = ?", (user["id"],) ).fetchall() tag_counts: dict[str, int] = {} for r in rows: raw = r["tags"] if raw: for t in raw.split(","): tag_clean = t.strip() if tag_clean: tag_counts[tag_clean] = tag_counts.get(tag_clean, 0) + 1 sorted_tags = sorted( [{"tag": k, "count": v} for k, v in tag_counts.items()], key=lambda x: x["count"], reverse=True ) return sorted_tags @app.get("/api/stats") async def api_stats(user: dict = Depends(require_user)): with get_db() as conn: total = conn.execute( "SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?", (user["id"],) ).fetchone()[0] today_added = conn.execute( """ SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now') """, (user["id"],) ).fetchone()[0] return { "total_cards": total, "today_added": today_added }