Files
bike_admin/app/main.py
T
Jake 2d63006bdc Rebrand to volundr; add deploy artifacts
- User-facing brand "bike-admin" -> "volundr" (page titles, top bar,
  login header, FastAPI title, README/AGENTS). Directory, remote,
  BIKE_ADMIN_* env prefix and bike_admin.db filename are unchanged.
- Add deploy/: systemd unit (bike-admin.service) and a production
  .env template (env.production.example).
2026-09-10 11:29:49 +01:00

511 lines
16 KiB
Python

from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
from fastapi import FastAPI, Request, Response, status
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, field_validator
from starlette.responses import RedirectResponse
from starlette.staticfiles import StaticFiles
from . import db as d
from . import services as svc
from .config import Settings, get_settings
from .security import (
SESSION_COOKIE,
check_csrf,
client_ip,
create_session,
csrf_token,
delete_session,
get_user_id_for_session,
hash_password,
is_throttled,
record_login_attempt,
verify_password,
)
APP_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(APP_DIR / "templates"))
# --- shared helpers --------------------------------------------
def _s(request: Request) -> Settings:
return get_settings()
def _redirect(location: str, status_code: int = status.HTTP_303_SEE_OTHER):
return RedirectResponse(location, status_code=status_code)
def _template_env(request: Request) -> dict:
s = get_settings()
base: dict = {
"request": request,
"event_types": s.event_types,
"today": date.today(),
"settings": s,
}
return base
def _csrf(request: Request) -> str:
s = get_settings()
return csrf_token(s.secret_key, request.cookies.get(SESSION_COOKIE))
def _parse_int(value: str | None) -> int | None:
if value is None:
return None
value = value.strip().replace(",", "")
if not value:
return None
return int(value)
def _errors_from(exc: Exception) -> dict[str, str]:
if hasattr(exc, "errors"):
return {e["loc"][-1]: e["msg"] for e in exc.errors()}
return {"_": str(exc)}
def current_user_id(request: Request) -> int | None:
token = request.cookies.get(SESSION_COOKIE)
with d.get_conn() as conn:
return get_user_id_for_session(conn, token)
def require_user(request: Request) -> int:
uid = current_user_id(request)
if uid is None:
raise FastAPIUnauthorized()
return uid
class FastAPIUnauthorized(Exception):
pass
# --- form models -------------------------------------------------
class BikeIn(BaseModel):
name: str
service_interval_miles: int | None = None
@field_validator("name")
@classmethod
def _name(cls, v: str) -> str:
v = v.strip()
if not v or len(v) > 100:
raise ValueError("Name must be 1-100 characters.")
return v
@field_validator("service_interval_miles")
@classmethod
def _interval(cls, v: int | None) -> int | None:
if v is not None and not (1 <= v <= 10_000_000):
raise ValueError("Interval must be a positive number of miles, or blank.")
return v
class EventIn(BaseModel):
type: str
event_date: date
mileage: int | None = None
cost: int | None = None
notes: str | None = None
@field_validator("type")
@classmethod
def _type(cls, v: str) -> str:
s = get_settings()
if v not in s.event_types:
raise ValueError(f"Type must be one of: {', '.join(s.event_types)}.")
return v
@field_validator("event_date")
@classmethod
def _date(cls, v: date) -> date:
if v > date.today():
raise ValueError("Date cannot be in the future.")
if v < date(1950, 1, 1):
raise ValueError("Date looks too old.")
return v
@field_validator("mileage")
@classmethod
def _mileage(cls, v: int | None) -> int | None:
if v is not None and v < 0:
raise ValueError("Mileage cannot be negative.")
return v
@field_validator("cost")
@classmethod
def _cost(cls, v: int | None) -> int | None:
if v is not None and v < 0:
raise ValueError("Cost cannot be negative.")
return v
def _form_str(form, key: str) -> str:
return str(form.get(key) or "").strip()
def _form_int(form, key: str) -> int | None:
return _parse_int(_form_str(form, key))
def _form_date(form, key: str) -> date:
return date.fromisoformat(_form_str(form, key) or date.today().isoformat())
def _form_notes(form) -> str | None:
v = _form_str(form, "notes")
return v or None
def _bike_in(form) -> BikeIn:
return BikeIn(
name=_form_str(form, "name"),
service_interval_miles=_form_int(form, "service_interval_miles"),
)
def _event_in(form) -> EventIn:
return EventIn(
type=_form_str(form, "type"),
event_date=_form_date(form, "event_date"),
mileage=_form_int(form, "mileage"),
cost=_form_int(form, "cost"),
notes=_form_notes(form),
)
# --- request context helpers (form data -> dict for re-render) ---
def _form_dict(form) -> dict:
"""Best-effort form dict for pre-fill on validation errors."""
out: dict = {}
try:
for key, value in form.items():
out[key] = value if isinstance(value, str) else str(value)
except AttributeError:
pass
return out
# --- app factory -------------------------------------------------
@asynccontextmanager
async def lifespan(_: FastAPI):
s = get_settings()
s.data_dir.mkdir(parents=True, exist_ok=True)
d.init_schema(s.resolved_db_path)
if s.password is None:
raise RuntimeError(
"BIKE_ADMIN_PASSWORD is not set. Refusing to start to avoid an "
"insecure default. Set BIKE_ADMIN_USERNAME and BIKE_ADMIN_PASSWORD "
"in the environment (or a .env file) and restart."
)
d.ensure_user(
s.resolved_db_path,
s.username,
s.password,
hash_password(s.password),
)
yield
app = FastAPI(title="volundr", lifespan=lifespan)
@app.exception_handler(FastAPIUnauthorized)
async def _unauthorized(request: Request, exc: FastAPIUnauthorized):
return RedirectResponse("/login", status_code=303)
app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static")
templates.env.filters["money"] = lambda v: f"{v:,}" if v is not None else ""
templates.env.globals["event_types"] = get_settings().event_types
templates.env.globals["status_class"] = svc.status_class
templates.env.globals["status_text"] = svc.status_text
templates.env.globals["renewal_class"] = svc.renewal_class
templates.env.globals["renewal_text"] = svc.renewal_text
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
h = response.headers
h.setdefault("X-Content-Type-Options", "nosniff")
h.setdefault("X-Frame-Options", "DENY")
h.setdefault("Referrer-Policy", "same-origin")
h.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
csp = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self'; "
"font-src 'self'; "
"script-src 'self'; "
"form-action 'self'; "
"base-uri 'self'; "
"frame-ancestors 'none'"
)
h.setdefault("Content-Security-Policy", csp)
return response
def _cookie_opts(request: Request, s: Settings) -> dict:
secure = bool(s.is_production) or (request.url is not None and request.url.scheme == "https")
return {
"path": "/",
"samesite": "lax",
"httponly": True,
"secure": secure,
"max_age": s.session_ttl_seconds,
}
def _csrf_ok(request: Request, form) -> bool:
s = get_settings()
token = request.cookies.get(SESSION_COOKIE)
return check_csrf(s.secret_key, token, _form_str(form, "csrf"))
def _login_render(request: Request, error: str | None) -> Response:
context = _template_env(request)
return templates.TemplateResponse(request, "login.html", {**context, "error": error})
def _bike_render(
request: Request, kind: str, bike, errors: dict, old: dict, action: str | None = None
) -> Response:
is_edit = kind == "edit"
if action is None:
action = request.url.path
context = _template_env(request)
context.update(
bike=bike,
errors=errors,
old=old,
csrf=_csrf(request),
action=action,
is_edit=is_edit,
)
return templates.TemplateResponse(request, f"bikes/{kind}.html", context)
# --- auth routes -------------------------------------------------
@app.get("/login", include_in_schema=False)
async def login_get(request: Request):
return _login_render(request, None)
@app.post("/login", include_in_schema=False)
async def login_post(request: Request):
s = get_settings()
form = await request.form()
username = _form_str(form, "username")
password = _form_str(form, "password")
ip = client_ip(request, s.trust_proxy)
with d.get_conn() as conn:
if is_throttled(conn, ip, s):
return _login_render(request, "Too many login attempts. Try again in a few minutes.")
cur = conn.execute(
"SELECT id, password_hash FROM users WHERE username = ?", (username,)
).fetchone()
ok = cur is not None and verify_password(password, cur["password_hash"])
if not ok:
record_login_attempt(conn, ip)
return _login_render(request, "Invalid username or password.")
token = create_session(conn, cur["id"], s)
nxt = _form_str(form, "next") or "/"
resp = RedirectResponse(nxt, status_code=303)
resp.set_cookie(SESSION_COOKIE, token, **_cookie_opts(request, s))
return resp
@app.get("/logout", include_in_schema=False)
async def logout_get(request: Request):
token = request.cookies.get(SESSION_COOKIE)
with d.get_conn() as conn:
delete_session(conn, token)
resp = _login_render(request, None)
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
# --- dashboard ---------------------------------------------------
@app.get("/", include_in_schema=False)
async def dashboard(request: Request):
require_user(request)
with d.get_conn() as conn:
action = svc.action_needed(conn)
soon = svc.due_soon(conn)
bikes = svc.all_summaries(conn)
context = _template_env(request)
context.update(
action=action,
due_soon=soon,
bikes=bikes,
csrf=_csrf(request),
)
return templates.TemplateResponse(request, "dashboard.html", context)
# --- bikes -------------------------------------------------------
@app.get("/bikes/new", include_in_schema=False)
async def bike_new_get(request: Request):
require_user(request)
return _bike_render(request, "new", None, {}, {})
@app.post("/bikes/new", include_in_schema=False)
async def bike_new_post(request: Request):
require_user(request)
form = await request.form()
if not _csrf_ok(request, form):
return _bike_render(request, "new", None, {"_": "CSRF check failed."}, _form_dict(form))
try:
bike_in = _bike_in(form)
except Exception as exc:
return _bike_render(request, "new", None, _errors_from(exc), _form_dict(form))
with d.get_conn() as conn:
bike_id = d.create_bike(conn, bike_in.name, bike_in.service_interval_miles)
return _redirect(f"/bikes/{bike_id}")
@app.get("/bikes/{bike_id}", include_in_schema=False)
async def bike_detail_get(request: Request, bike_id: int):
require_user(request)
s = get_settings()
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id)
if bike is None:
return _redirect("/")
summary = svc.summarize_bike(conn, bike)
events = d.list_events(conn, bike.id)
context = _template_env(request)
context.update(
bike=bike,
summary=summary,
events=events,
csrf=_csrf(request),
event_types=s.event_types,
)
return templates.TemplateResponse(request, "bikes/detail.html", context)
@app.get("/bikes/{bike_id}/edit", include_in_schema=False)
async def bike_edit_get(request: Request, bike_id: int):
require_user(request)
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id)
if bike is None:
return _redirect("/")
return _bike_render(request, "edit", bike, {}, {})
@app.post("/bikes/{bike_id}/edit", include_in_schema=False)
async def bike_edit_post(request: Request, bike_id: int):
require_user(request)
form = await request.form()
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id)
if bike is None:
return _redirect("/")
if not _csrf_ok(request, form):
errs = {"_": "CSRF check failed."}
return _bike_render(request, "edit", bike, errs, _form_dict(form))
try:
bike_in = _bike_in(form)
except Exception as exc:
return _bike_render(request, "edit", bike, _errors_from(exc), _form_dict(form))
d.update_bike(conn, bike_id, bike_in.name, bike_in.service_interval_miles)
return _redirect(f"/bikes/{bike_id}")
@app.post("/bikes/{bike_id}/delete", include_in_schema=False)
async def bike_delete(request: Request, bike_id: int):
s = get_settings()
token = request.cookies.get(SESSION_COOKIE)
form = await request.form()
if not check_csrf(s.secret_key, token, _form_str(form, "csrf")):
return _redirect(f"/bikes/{bike_id}")
with d.get_conn() as conn:
d.delete_bike(conn, bike_id)
return _redirect("/", status.HTTP_303_SEE_OTHER)
# --- events ------------------------------------------------------
@app.post("/bikes/{bike_id}/events", include_in_schema=False)
async def event_create(request: Request, bike_id: int):
"""Create an event; return the service card + fresh list (HTMX swap)."""
require_user(request)
s = get_settings()
form = await request.form()
if not _csrf_ok(request, form):
return _render_bike_partial(request, s, bike_id, errors={"_": "CSRF check failed."})
try:
event_in = _event_in(form)
except Exception as exc:
errors = _errors_from(exc)
return _render_bike_partial(request, s, bike_id, errors=errors, old=_form_dict(form))
with d.get_conn() as conn:
if d.get_bike(conn, bike_id) is None:
return _redirect("/")
d.create_event(
conn,
bike_id,
event_in.type,
event_in.event_date,
event_in.mileage,
event_in.cost,
event_in.notes,
)
return _render_bike_partial(request, s, bike_id)
def _render_bike_partial(
request, s: Settings, bike_id: int, errors: dict | None = None, old: dict | None = None
) -> Response:
"""Render the service card + fresh event list (HTMX swap into #bike-body)."""
base: dict = {
"request": request,
"today": date.today(),
"event_types": s.event_types,
"settings": s,
"bike_id": bike_id,
"csrf": csrf_token(s.secret_key, request.cookies.get(SESSION_COOKIE)),
}
with d.get_conn() as conn:
bike = d.get_bike(conn, bike_id) or d.Bike(bike_id, "?", None)
summary = svc.summarize_bike(conn, bike)
events = d.list_events(conn, bike.id)
base.update(bike=bike, summary=summary, events=events, old=old or {}, errors=errors or {})
return templates.TemplateResponse(request, "events/partial.html", base)
@app.post("/events/{event_id}/delete", include_in_schema=False)
async def event_delete(request: Request, event_id: int):
s = get_settings()
token = request.cookies.get(SESSION_COOKIE)
form = await request.form()
if not check_csrf(s.secret_key, token, _form_str(form, "csrf")):
return _redirect("/")
with d.get_conn() as conn:
r = conn.execute("SELECT bike_id FROM events WHERE id = ?", (event_id,)).fetchone()
if r is None:
return _redirect("/")
bike_id = r["bike_id"]
d.delete_event(conn, event_id)
return _redirect(f"/bikes/{bike_id}")