feat(core): implement secure MVP core with dictionary search, auth, and CRUD

This commit is contained in:
Antigravity committed 2026-09-22 13:27:11 +00:00
1 parent 4dbc53890a
commit 813d567b0e
14 files changed
+2960

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
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.get("/api/audio")
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
}