feat: v6.3.0 API publique complete v2 (REST /api/v2, scopes, OpenAPI)
- Router api_v2.py (~100 endpoints) : tokens, users, workspaces/members, collections, pages, proprietes, vues/dashboards, commentaires/mentions, notifications, favoris/tags/recents, partage/publish, historique, sprints, templates, export/import, forges, recherche FTS, admin, webhooks CRUD - Helpers api_v2_helpers.py : Bearer unifie (sha256/expires_at/extension_devices), scopes hierarchiques read<write<admin, pagination + X-Total-Count, ISO-8601, RFC 7807, idempotence, audit, rate-limit par token - Migration 20 : api_tokens.scopes/expires_at, webhook_deliveries, api_audit_log, idempotency_keys - main.py : handler d'erreurs unifie StarletteHTTPException, /docs + /redoc - config : PUBLIC_API_INSECURE_OK (dev only), API_V2_RATE_LIMIT_PER_TOKEN - OpenAPI docs/openapi-v2.json (402 chemins), tests/test_public_api_v2.py (24) - Docs : CHANGELOG (v6.2.0/6.2.1 clipper + v6.3.0), ROADMAP, API_GUIDE_V6, V6_Web_Clipper, README, ARCHITECTURE, /help - Suite complete 668 verte, ruff OK
This commit is contained in:
@@ -43,6 +43,10 @@ class Settings(BaseSettings):
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 60 # per minute
|
||||
|
||||
# Public API v2 (v6.3.0)
|
||||
public_api_insecure_ok: bool = False # if True, fd-public-key is accepted (dev only)
|
||||
api_v2_rate_limit_per_token: int = 300 # req/min per token for /api/v2
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite:////data/flowdeck.db"
|
||||
|
||||
|
||||
+30
-12
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
@@ -37,6 +38,7 @@ from app.routers import (
|
||||
webhooks,
|
||||
workspace,
|
||||
)
|
||||
from app.routers.api_v2 import router as api_v2_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
@@ -111,9 +113,9 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.2.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
version="6.3.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -157,6 +159,7 @@ app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
app.include_router(api_v2_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -237,12 +240,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
|
||||
|
||||
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||
raised exceptions and route-miss 404s.
|
||||
"""
|
||||
status = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
if status == 404:
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
# Non-404: RFC7807 for /api/v2
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
|
||||
|
||||
@@ -843,6 +843,67 @@ def _migration_web_clipper(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(20, "v6.3.0: api v2 — scopes, expires_at, audit, webhooks, idempotency")
|
||||
def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
"""v6.3.0 — API publique complète v2.
|
||||
|
||||
``api_tokens`` — adds ``scopes`` + ``expires_at`` (idempotent ALTER).
|
||||
``webhook_deliveries`` — delivery log for outbound webhooks (CRUD simple phase 1).
|
||||
``api_audit_log`` — immutable audit trail for v2 mutations.
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN expires_at TIMESTAMP")
|
||||
# Backfill existing tokens without scopes
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
token_id INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL DEFAULT '',
|
||||
resource_id TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_user ON api_audit_log(user_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_resource ON api_audit_log(resource_type, resource_id)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
http_code INTEGER,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_wd_webhook ON webhook_deliveries(webhook_id, created_at)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
response_json TEXT NOT NULL DEFAULT '{}',
|
||||
status_code INTEGER NOT NULL DEFAULT 200,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_idemp_user ON idempotency_keys(user_id, created_at)")
|
||||
|
||||
|
||||
@register(17, "v6.0.0: sync_version columns")
|
||||
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -608,6 +608,22 @@ page. If a page with the same title already exists, the offline copy is renamed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔌 API publique v2</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>💡 Tips</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
|
||||
@@ -40,6 +40,9 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""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 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
|
||||
|
||||
# ── 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:
|
||||
pass
|
||||
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:
|
||||
pass
|
||||
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:
|
||||
pass
|
||||
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):
|
||||
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
user = get_bearer_user(request, authorization)
|
||||
scopes = user.get("_token_scopes") or "read"
|
||||
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:
|
||||
pass
|
||||
|
||||
# ── 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:
|
||||
pass
|
||||
Reference in New Issue
Block a user