- 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
318 lines
14 KiB
Python
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")
|