Découpe par concern de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) en package `app/routers/api_v2/` : - 12 modules de routes : collections 566 L (23 r.), engagement 338 (21), workspaces 230 (9), templates_io 205 (9), webhooks 195 (8), identity 195 (7), views 164 (8), sharing 160 (8), properties 151 (7), planning 148 (7), projects 93 (4), admin 91 (4) - `_common.py` : helpers partagés (_hash, _v2_rate_check) - `__init__.py` : router = APIRouter(prefix="/api/v2") + include_router sur les routers de sections (sans prefix, tags « api-v2 ») Preuve contractuelle : `docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte (0 changement de chemin/tag/operation_id). Seul importateur (app/main.py : from app.routers.api_v2 import router) fonctionne via le package. En-tête d'imports copié par module puis émondé par ruff --fix (143 imports morts), I001 réordonnés. Reste A28 : dashboard.py 2 735 L, collections.py 2 622 L, board.py 2 101 L (même recette, lots suivants). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
196 lines
8.6 KiB
Python
196 lines
8.6 KiB
Python
"""FlowDeck — Public API v2 : identity.
|
|
|
|
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.db import get_conn
|
|
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
|
audit_log,
|
|
check_idempotency,
|
|
check_v2_rate_limit,
|
|
get_bearer_user,
|
|
has_scope,
|
|
paginate_headers,
|
|
parse_pagination,
|
|
require_scope,
|
|
row_to_dict,
|
|
store_idempotency,
|
|
to_iso8601,
|
|
validate_scopes_input,
|
|
)
|
|
|
|
from ._common import _hash, _v2_rate_check
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["api-v2"])
|
|
|
|
|
|
|
|
@router.post("/tokens")
|
|
def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
name = (body.get("name") or "API token").strip()[:100]
|
|
scopes = validate_scopes_input(body.get("scopes") or "read,write")
|
|
expires_at = body.get("expires_at")
|
|
# Idempotency
|
|
idem = check_idempotency(request, user["id"])
|
|
if idem:
|
|
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
|
token = f"fd_{secrets.token_urlsafe(32)}"
|
|
prefix = token[:12]
|
|
th = _hash(token)
|
|
exp_val = None
|
|
if expires_at:
|
|
try:
|
|
# accept ISO string
|
|
exp_val = str(expires_at)
|
|
# validate parse
|
|
datetime.fromisoformat(exp_val.replace("Z", "+00:00"))
|
|
except Exception as err:
|
|
raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err
|
|
with get_conn() as conn:
|
|
try:
|
|
cur = conn.execute(
|
|
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
(user["id"], name, th, prefix, scopes, exp_val),
|
|
)
|
|
conn.commit()
|
|
tid = cur.lastrowid
|
|
except Exception as e:
|
|
raise HTTPException(409, f"Token creation failed: {e}") from None
|
|
row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone()
|
|
audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request)
|
|
data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
|
key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip()
|
|
if key:
|
|
store_idempotency(key, user["id"], data, 200)
|
|
return data
|
|
|
|
|
|
@router.get("/tokens")
|
|
def list_tokens(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
d["created_at"] = to_iso8601(d.get("created_at"))
|
|
d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None
|
|
d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None
|
|
# never expose hash
|
|
out.append({k: v for k, v in d.items() if k != "token_hash"})
|
|
return {"tokens": out}
|
|
|
|
|
|
@router.delete("/tokens/{token_id}")
|
|
def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Token not found")
|
|
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
|
raise HTTPException(403, "Not your token")
|
|
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
|
conn.commit()
|
|
audit_log(user, "token.revoke", "api_token", token_id, "", request)
|
|
return {"id": token_id, "status": "revoked"}
|
|
|
|
|
|
@router.post("/tokens/{token_id}/rotate")
|
|
def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Token not found")
|
|
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
|
raise HTTPException(403, "Not your token")
|
|
# revoke old
|
|
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
|
new_token = f"fd_{secrets.token_urlsafe(32)}"
|
|
th = _hash(new_token)
|
|
prefix = new_token[:12]
|
|
cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write"))
|
|
conn.commit()
|
|
nid = cur.lastrowid
|
|
audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request)
|
|
return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"}
|
|
|
|
|
|
@router.get("/users/me")
|
|
def get_me(request: Request, authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "User not found")
|
|
d = row_to_dict(row)
|
|
# parse json prefs
|
|
for k in ("notification_prefs", "sidebar_config"):
|
|
if isinstance(d.get(k), str):
|
|
try:
|
|
d[k] = json.loads(d[k] or "{}")
|
|
except Exception:
|
|
logger.exception("get_me")
|
|
# never expose secrets
|
|
return d
|
|
|
|
|
|
@router.patch("/users/me")
|
|
def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
|
user = require_scope("write")(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"}
|
|
updates = {}
|
|
for k in allowed:
|
|
if k in body:
|
|
updates[k] = body[k]
|
|
if not updates:
|
|
raise HTTPException(400, "No updatable fields")
|
|
# validation
|
|
if "email" in updates and updates["email"] and "@" not in str(updates["email"]):
|
|
raise HTTPException(400, "Invalid email")
|
|
with get_conn() as conn:
|
|
sets = []
|
|
params = []
|
|
for k, v in updates.items():
|
|
if k in ("notification_prefs", "sidebar_config"):
|
|
v = json.dumps(v) if isinstance(v, (dict, list)) else str(v)
|
|
sets.append(f"{k}=?")
|
|
params.append(v)
|
|
params.append(user["id"])
|
|
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
|
|
conn.commit()
|
|
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
|
audit_log(user, "user.update", "user", user["id"], "", request)
|
|
return row_to_dict(row)
|
|
|
|
|
|
@router.get("/users/search")
|
|
def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)):
|
|
user = get_bearer_user(request, authorization)
|
|
_v2_rate_check(request, user)
|
|
q = (q or request.query_params.get("q") or "").strip()
|
|
if not q:
|
|
return {"users": []}
|
|
like = f"%{q}%"
|
|
with get_conn() as conn:
|
|
rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall()
|
|
return {"users": [dict(r) for r in rows]}
|