353 lines
13 KiB
Python
353 lines
13 KiB
Python
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
|
|
}
|