Files
snotes/backend/main.py
2026-09-22 16:11:20 -07:00

189 lines
5.7 KiB
Python

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")