Files
flowdeck/app/services/api_v2_helpers.py
T
bruno 937ecfc2e0
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A30 + A37 + A39 + A40 + A41 — fin du P2/P3 XS/S (v7.4.0)
- A30 — `require_scope()` câblé : 69 sites stricts de api_v2.py passent par la
  factory (Bearer + scope en 1 appel, contrôle manuel supprimé) ; sémantique
  alignée sur celle des handlers (pas de default "read" → 0 changement de
  comportement) ; 12 top-level morts supprimés (0 ref app ET tests) :
  unsync_block, find_referring, _b64url, strip_markdown, format_number,
  get_auto_property_value, get_next_unique_id, local_date_in_tz,
  verify_device_token, _get_dynamic_groups, _require_user_gitea,
  validate_upload_request
- A37 — CORS sans `*` : origines = app_base_url + allow_origin_regex
  (localhost/dev, origines d'extension pour le Web Clipper), méthodes et
  entêtes minutées, allow_credentials explicite + test test_cors_no_star
- A39 — htmx : décision « rien » documentée (32 attributs hx-* réels sur 6
  templates, conversion = refonte du view-switching sans test E2E)
- A40 — version d'assets à source unique : ENV.globals["asset_version"] lu au
  boot depuis le fichier VERSION ; littéraux `?v=` de base.html éliminés ;
  test test_asset_version_single_source
- A41 — app.css : 91 règles mortes purgées (-10 274 octets, 121 618 → 111 344),
  scan templates/JS/CSS/Python à 0 référence

suite **1031/1031** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.4.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2026-10-01 09:30:37 -04:00

318 lines
14 KiB
Python

"""FlowDeck — helpers for API v2 (v6.3.0).
Pagination, ISO-8601, RFC7807 errors, hierarchical scopes, Bearer auth.
No duplication: thin wrappers over existing services.
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from datetime import UTC, datetime
from typing import Any
from fastapi import Header, HTTPException, Request
from fastapi.responses import JSONResponse
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
# ── ISO-8601 ──────────────────────────────────────────────────────────────
def to_iso8601(value: str | None) -> str | None:
if not value:
return None
# SQLite stores "YYYY-MM-DD HH:MM:SS" or with T; convert to UTC Z
try:
# try with seconds
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S.%f"):
try:
dt = datetime.strptime(value[:19], fmt[:8] if "." in value else fmt)
# SQLite has no tz => assume UTC
dt = dt.replace(tzinfo=UTC)
return dt.isoformat().replace("+00:00", "Z")
except ValueError:
continue
# fallback: if already ISO with T/Z, return as-is
if "T" in value:
return value
return value
except Exception:
return value
def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at", "created_at_ts", "last_login", "joined_at", "accessed_at", "fired_at", "start_date", "end_date", "logged_at", "last_seen_at", "last_used_at", "verified_at", "last_login_at")) -> dict:
if row is None:
return {}
d = dict(row)
for k in list(d.keys()):
if k in iso_fields and d[k]:
iso = to_iso8601(str(d[k]))
if iso:
d[k] = iso
# parse *_json columns
if k.endswith("_json") and isinstance(d[k], str):
try:
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
except Exception:
logger.exception("row_to_dict")
return d
# ── Pagination ────────────────────────────────────────────────────────────
def parse_pagination(request: Request, default_limit: int = 30, max_limit: int = 100) -> tuple[int, int]:
try:
limit = int(request.query_params.get("limit", str(default_limit)))
except ValueError:
limit = default_limit
try:
offset = int(request.query_params.get("offset", "0"))
except ValueError:
offset = 0
limit = max(1, min(limit, max_limit))
offset = max(0, offset)
return limit, offset
def paginate_headers(total: int) -> dict[str, str]:
return {"X-Total-Count": str(total)}
# ── Scopes (hierarchical: read < write < admin) ──────────────────────────
SCOPE_RANK = {"read": 1, "write": 2, "admin": 3}
VALID_SCOPES = set(SCOPE_RANK.keys())
def normalize_scopes(raw: str | None) -> set[str]:
if not raw:
return set()
parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
return {p for p in parts if p in VALID_SCOPES}
def has_scope(token_scopes: str | None, required: str) -> bool:
req_rank = SCOPE_RANK.get(required, 99)
# token with higher rank satisfies lower requirement
# admin => write => read
token_set = normalize_scopes(token_scopes)
if not token_set:
return False
# effective rank = max rank among token scopes
eff = max((SCOPE_RANK.get(s, 0) for s in token_set), default=0)
return eff >= req_rank
def validate_scopes_input(scopes_raw: str | None) -> str:
if not scopes_raw:
return "read"
parts = [p.strip().lower() for p in scopes_raw.split(",") if p.strip()]
for p in parts:
if p not in VALID_SCOPES:
raise HTTPException(status_code=400, detail=f"Invalid scope: {p}. Valid: read, write, admin")
if not parts:
return "read"
# dedup preserve order
seen = []
for p in parts:
if p not in seen:
seen.append(p)
return ",".join(seen)
# ── Bearer auth (unified) ─────────────────────────────────────────────────
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def resolve_bearer_token(token: str) -> dict | None:
"""Resolve Bearer token to user dict. Returns None if invalid/expired/revoked.
Supports api_tokens (hashed), extension_devices (hashed), and legacy user_tokens (plain).
"""
if not token:
return None
# dev-only fallback
if token == "fd-public-key":
if not settings.public_api_insecure_ok:
return None
# return a synthetic admin-like user? Use first admin or id 1
with get_conn() as conn:
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
if row:
d = dict(row)
d["_token_id"] = None
d["_token_scopes"] = "read,write,admin"
d["_token_hash"] = None
return d
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users ORDER BY id LIMIT 1").fetchone()
if row:
d = dict(row)
d["_token_id"] = None
d["_token_scopes"] = "read,write,admin"
d["_token_hash"] = None
return d
return None
th = _hash_token(token)
with get_conn() as conn:
# 1) api_tokens
row = conn.execute("SELECT id, user_id, scopes, expires_at, revoked FROM api_tokens WHERE token_hash=?", (th,)).fetchone()
if row:
if row["revoked"]:
return None
exp = row["expires_at"]
if exp:
try:
# compare as timestamp; SQLite format "YYYY-MM-DD HH:MM:SS"
# parse to epoch
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) if "T" in str(exp) else datetime.strptime(str(exp)[:19], "%Y-%m-%d %H:%M:%S")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
if dt.timestamp() < time.time():
return None
except Exception:
logger.exception("resolve_bearer_token")
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u:
d = dict(u)
d["_token_id"] = row["id"]
d["_token_scopes"] = row["scopes"] or "read,write"
d["_token_hash"] = th
# touch last_used_at best-effort
try:
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
except Exception:
logger.exception("resolve_bearer_token")
return d
# 2) extension_devices
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
if row:
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u:
d = dict(u)
d["_token_id"] = None
d["_token_scopes"] = row["scopes"] or "read,write"
d["_token_hash"] = th
return d
# 3) legacy user_tokens (plain storage)
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
if row:
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
if u:
d = dict(u)
d["_token_id"] = None
d["_token_scopes"] = "read,write"
d["_token_hash"] = th
return d
return None
def get_bearer_user(request: Request, authorization: str | None = Header(default=None)) -> dict:
# Prefer explicit Authorization header, fallback to lowercase
auth = authorization or request.headers.get("authorization") or request.headers.get("Authorization") or ""
if not auth or not auth.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="API token required. Use Authorization: Bearer <token>")
token = auth[7:].strip()
user = resolve_bearer_token(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired API token")
return user
def require_scope(required: str):
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
user = get_bearer_user(request, authorization)
# Pas de default "read" : identique au contrôle manuel des handlers
# (un jeton sans scope est refusé, quel que soit le scope demandé).
scopes = user.get("_token_scopes")
if not has_scope(scopes, required):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
return user
return _dep
# ── RFC 7807 ──────────────────────────────────────────────────────────────
def problem_response(request: Request, exc: HTTPException) -> JSONResponse:
title_map = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
409: "Conflict",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
}
status = exc.status_code
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
body = {
"type": f"https://flowdeck/api/errors/{status}",
"title": title_map.get(status, "Error"),
"status": status,
"detail": detail,
"instance": str(request.url.path),
}
return JSONResponse(status_code=status, content=body, media_type="application/problem+json")
# ── Audit ─────────────────────────────────────────────────────────────────
def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str | int = "", detail: str = "", request: Request | None = None) -> None:
try:
ip = ""
if request and request.client:
ip = request.client.host or ""
with get_conn() as conn:
conn.execute(
"INSERT INTO api_audit_log (user_id, token_id, action, resource_type, resource_id, ip_address, detail) VALUES (?, ?, ?, ?, ?, ?, ?)",
(user.get("id"), user.get("_token_id"), action, resource_type, str(resource_id), ip, detail[:1000]),
)
conn.commit()
except Exception:
logger.exception("audit_log")
# ── Rate limit per token (in-memory) ─────────────────────────────────────
_v2_rate_store: dict[str, tuple[float, int]] = {}
def check_v2_rate_limit(token_hash: str | None, ip: str) -> bool:
"""Return True if allowed, False if 429. Uses api_v2_rate_limit_per_token."""
key = token_hash or f"ip:{ip}"
now = time.time()
window = 60.0
max_req = settings.api_v2_rate_limit_per_token
start, count = _v2_rate_store.get(key, (now, 0))
if now - start > window:
_v2_rate_store[key] = (now, 1)
return True
if count >= max_req:
return False
_v2_rate_store[key] = (start, count + 1)
return True
# ── Idempotency ───────────────────────────────────────────────────────────
def check_idempotency(request: Request, user_id: int) -> dict | None:
key = request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key")
if not key:
return None
key = key.strip()[:200]
if not key:
return None
with get_conn() as conn:
row = conn.execute("SELECT response_json, status_code FROM idempotency_keys WHERE key=? AND user_id=?", (key, user_id)).fetchone()
if row:
try:
data = json.loads(row["response_json"])
return {"data": data, "status": row["status_code"], "key": key}
except Exception:
return None
return None
def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200) -> None:
if not key:
return
try:
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO idempotency_keys (key, user_id, response_json, status_code) VALUES (?, ?, ?, ?)",
(key.strip()[:200], user_id, json.dumps(data), status_code),
)
conn.commit()
except Exception:
logger.exception("store_idempotency")