"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens).""" from __future__ import annotations import hashlib import logging from secrets import token_urlsafe from fastapi import APIRouter, Depends, Header, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn logger = logging.getLogger(__name__) router = APIRouter(tags=["public-api"], prefix="/api/v1") DEFAULT_TOKEN = "fd-public-key" def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _token_owner(token: str) -> dict | None: """Resolve an api_tokens row by its sha256 hash (revoked → None).""" with get_conn() as conn: row = conn.execute( "SELECT id, user_id, name FROM api_tokens WHERE token_hash=? AND revoked=0", (_hash_token(token),), ).fetchone() if not row: return None conn.execute( "UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],) ) conn.commit() return dict(row) def verify_token(authorization: str | None = Header(None)): if not authorization or not authorization.startswith("Bearer "): raise HTTPException(401, "API token required. Generate one via Settings → API tokens.") token = authorization[7:] # strip "Bearer " if token == DEFAULT_TOKEN: from app.config import settings as _s if not _s.public_api_insecure_ok: raise HTTPException(401, "Default token disabled. Set PUBLIC_API_INSECURE_OK=true in dev or use a real Bearer token.") return token with get_conn() as conn: row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone() if row: return token owner = _token_owner(token) if not owner: raise HTTPException(403, "Invalid API token") return token @router.post("/token") def generate_token(request: Request): """Generate a public API access token (A4 : session obligatoire — plus de « legacy shared token » `user_id=0` créable par un anonymous).""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(401, "Authentication required") token = f"fd_{token_urlsafe(24)}" with get_conn() as conn: conn.execute( "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)", (user["id"], "API token", _hash_token(token), token[:12]), ) conn.commit() return {"token": token, "note": "Use as: Authorization: Bearer "} @router.get("/collections", dependencies=[Depends(verify_token)]) def public_list_collections(request: Request): with get_conn() as conn: rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall() return {"collections": [dict(r) for r in rows]} @router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)]) def public_get_collection(request: Request, collection_id: int): with get_conn() as conn: coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(404, "Not found") pages = conn.execute( "SELECT id, title, icon, position, property_values_json, created_at, updated_at FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() return {"collection": dict(coll), "pages": [dict(p) for p in pages]} @router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)]) def public_list_pages(request: Request, collection_id: int): with get_conn() as conn: pages = conn.execute( "SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position", (collection_id,), ).fetchall() return {"pages": [dict(p) for p in pages]} @router.get("/pages/{page_id}", dependencies=[Depends(verify_token)]) def public_get_page(request: Request, page_id: int): with get_conn() as conn: p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() if not p: raise HTTPException(404, "Not found") return dict(p) @router.get("/my-tasks", dependencies=[Depends(verify_token)]) def public_my_tasks(request: Request): """Public API: list tasks (requires valid token).""" with get_conn() as conn: pages = conn.execute( "SELECT id, title, icon, property_values_json FROM collection_pages WHERE parent_id IS NULL ORDER BY created_at DESC LIMIT 50" ).fetchall() return {"tasks": [dict(p) for p in pages]}