from __future__ import annotations from contextlib import asynccontextmanager import structlog from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from backend import auth, database, notes from backend.config import settings from backend.models import LoginRequest, NoteCreate, NoteUpdate, UserSelect logger = structlog.get_logger() @asynccontextmanager async def lifespan(app: FastAPI): await database.init_db() yield app = FastAPI(title="snotes", lifespan=lifespan) def client_ip(request: Request) -> str | None: fwd = request.headers.get("x-forwarded-for") if fwd: return fwd.split(",")[0].strip() return request.client.host if request.client else None def client_ua(request: Request) -> str | None: return request.headers.get("user-agent") def require_access(request: Request) -> None: ip = client_ip(request) if auth.is_lan(ip): return token = request.cookies.get("snotes_auth") if token and auth.check_auth_cookie(token): return raise HTTPException(status_code=401, detail="authentication required") def current_user(request: Request) -> str: username = request.cookies.get("snotes_user") return username or settings.user_list[0] # --------------------------------------------------------------------------- # SPA + static # --------------------------------------------------------------------------- @app.get("/", include_in_schema=False) async def index() -> FileResponse: return FileResponse("static/index.html") @app.get("/favicon.ico", include_in_schema=False) async def favicon() -> FileResponse: return FileResponse("static/snotes-main.png") # --------------------------------------------------------------------------- # Auth / users # --------------------------------------------------------------------------- @app.get("/api/me") async def me(request: Request) -> dict: ip = client_ip(request) return { "user": current_user(request), "local": auth.is_lan(ip), "authed": not auth.is_lan(ip) and bool( request.cookies.get("snotes_auth") and auth.check_auth_cookie(request.cookies["snotes_auth"]) ), } @app.get("/api/users") async def users(_: None = Depends(require_access)) -> list[dict]: return await database.list_users() @app.post("/api/users/select") async def select_user(body: UserSelect, _: None = Depends(require_access)) -> Response: if body.username not in settings.user_list and body.username != settings.common_space: raise HTTPException(status_code=404, detail="unknown user") resp = JSONResponse({"username": body.username}) resp.set_cookie("snotes_user", body.username, httponly=True, samesite="lax") return resp @app.post("/api/auth/login") async def login(body: LoginRequest) -> Response: if not auth.remote_password_ok(body.password): raise HTTPException(status_code=401, detail="invalid password") resp = JSONResponse({"ok": True}) resp.set_cookie("snotes_auth", auth.issue_auth_cookie(), httponly=True, samesite="lax") return resp # --------------------------------------------------------------------------- # Notes # --------------------------------------------------------------------------- @app.post("/api/notes") async def create_note(body: NoteCreate, request: Request, _: None = Depends(require_access)): username = current_user(request) if not body.body.strip(): raise HTTPException(status_code=400, detail="empty note") try: note = await notes.create_note( username, body.body, ip=client_ip(request), ua=client_ua(request) ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return note @app.get("/api/notes") async def list_notes(request: Request, _: None = Depends(require_access)): username = current_user(request) return await notes.list_notes(username) @app.get("/api/notes/recent") async def recent(_: None = Depends(require_access)): return await notes.list_recent() @app.get("/api/notes/{note_id}") async def get_note(note_id: str, _: None = Depends(require_access)): note = await notes.get_note(note_id) if note is None: raise HTTPException(status_code=404, detail="note not found") return note @app.put("/api/notes/{note_id}") async def update_note( note_id: str, body: NoteUpdate, request: Request, _: None = Depends(require_access) ): note = await notes.update_note(note_id, body.body, ip=client_ip(request), ua=client_ua(request)) if note is None: raise HTTPException(status_code=404, detail="note not found") return note @app.delete("/api/notes/{note_id}") async def delete_note(note_id: str, request: Request, _: None = Depends(require_access)): ok = await notes.delete_note(note_id, ip=client_ip(request), ua=client_ua(request)) if not ok: raise HTTPException(status_code=404, detail="note not found") return {"ok": True} @app.get("/api/notes/{note_id}/history") async def note_history(note_id: str, _: None = Depends(require_access)): return await notes.get_history(note_id) # --------------------------------------------------------------------------- # Tags / search # --------------------------------------------------------------------------- @app.get("/api/tags") async def tags(_: None = Depends(require_access)): return await notes.tag_counts() @app.get("/api/search") async def search(q: str, _: None = Depends(require_access)): if not q.strip(): return [] return await notes.search_notes(q) # Mount static assets last so /api routes take precedence app.mount("/static", StaticFiles(directory="static"), name="static")