feat(share,permissions): partage de page par groupes (page_shares)
- app/db.py: ajout colonne shared_with_group_id (FK user_groups, migration
backfill v5.x) dans page_shares
- app/routers/sharing.py: POST /api/pages/{id}/share accepte group_id
(upsert, verif FK groupe), GET /shares expose kind/group_name, synchro
bidirectionnelle avec page_permissions (mirror grant/revoke) pour que
PermissionManager donne un acces effectif (view/comment/edit) aux membres
du groupe; PUT/DELETE gardent le miroir a jour
- app/routers/board.py, library.py: received/made incluent les partages via
groupes (JOIN group_members)
- app/templates/_page_editor_content.html, _page_editor_scripts.html:
dialogue Share — invite groups (fetch /api/v2/groups, filtre deja partages),
pickInviteGroup, shareInvite(group_id), rendu accessList avec avatar groupe
- tests/test_share_groups.py: 10 tests (CRUD groupe, kind, miroir ACL)
Chore: inclut evolutions v6.4.0 deja en working copy (webhooks prod,
migrations, sync, config/main) pour garder l'arbre coherent.
This commit is contained in:
+60
-20
@@ -2184,15 +2184,32 @@ async def admin_audit_logs_v2(request: Request, limit: int = 50, authorization:
|
||||
rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
|
||||
return {"logs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
# ── Webhooks (CRUD simple) ────────────────────────────────────────────────
|
||||
# ── Webhooks (v6.4.0 prod) ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/webhooks/events")
|
||||
async def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Catalogue of deliverable events (+ wildcard syntax)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
from app.services.webhook_outbound import EVENTS
|
||||
return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]}
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"webhooks": [dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
@@ -2204,18 +2221,25 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
from app.services.webhook_outbound import EVENTS, _event_matches
|
||||
url = (body.get("url") or "").strip()
|
||||
event = (body.get("event") or "page.created").strip()
|
||||
secret = (body.get("secret") or "").strip()
|
||||
if not url or not url.startswith("http"):
|
||||
raise HTTPException(400, "url must start with http")
|
||||
if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))):
|
||||
raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events")
|
||||
# make sure the pattern matches at least one known event
|
||||
if not any(_event_matches(event, e) for e in EVENTS):
|
||||
raise HTTPException(400, f"Event pattern '{event}' matches no known event")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
|
||||
wid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "webhook.create", "webhook", wid, url, request)
|
||||
return {"id": wid, "status": "created", "webhook": dict(row) if row else {}}
|
||||
return {"id": wid, "status": "created", "webhook": dict(row) if row else {},
|
||||
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
|
||||
|
||||
@router.patch("/webhooks/{webhook_id}")
|
||||
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
@@ -2262,24 +2286,40 @@ async def test_webhook_v2(webhook_id: int, request: Request, authorization: str
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
# fire test ping (best-effort, record delivery)
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event
|
||||
await fire_event("ping", {"webhook_id": webhook_id, "test": True})
|
||||
conn.execute("INSERT INTO webhook_deliveries (webhook_id, status, http_code, payload) VALUES (?, 'delivered', 200, ?)", (webhook_id, json.dumps({"event": "ping"})))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
try:
|
||||
conn.execute("INSERT INTO webhook_deliveries (webhook_id, status, http_code, error) VALUES (?, 'failed', 0, ?)", (webhook_id, str(e)[:1000]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return {"webhook_id": webhook_id, "status": "tested"}
|
||||
# live delivery via the prod dispatcher (HMAC + retry + journal),
|
||||
# direct to this subscription only (no wildcard fan-out)
|
||||
from app.services.webhook_outbound import deliver_to_sub
|
||||
ok = await deliver_to_sub(webhook_id, row["url"], "ping",
|
||||
{"webhook_id": webhook_id, "test": True},
|
||||
row["secret"] or "")
|
||||
audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request)
|
||||
return {"webhook_id": webhook_id, "status": "tested", "delivered": ok}
|
||||
|
||||
@router.get("/webhooks/{webhook_id}/deliveries")
|
||||
async def list_deliveries_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
async def list_deliveries_v2(webhook_id: int, request: Request,
|
||||
status: str | None = None,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_deliveries WHERE webhook_id=? ORDER BY created_at DESC LIMIT 50", (webhook_id,)).fetchall()
|
||||
return {"deliveries": [row_to_dict(r) for r in rows]}
|
||||
if status:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?",
|
||||
(webhook_id, status)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, status, limit, offset)).fetchall()
|
||||
else:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?",
|
||||
(webhook_id,)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, limit, offset)).fetchall()
|
||||
return JSONResponse(
|
||||
content={"deliveries": [row_to_dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
+15
-6
@@ -612,12 +612,21 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
|
||||
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
def _entry(r, icon):
|
||||
return {
|
||||
|
||||
+13
-4
@@ -235,15 +235,24 @@ async def library_shared(
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Page ids the user shares toward others (nominal page_shares) or receives
|
||||
# (direct shares + group shares via group_members)
|
||||
with get_conn() as conn:
|
||||
made_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
try:
|
||||
recv_rows = conn.execute(
|
||||
"""SELECT DISTINCT s.page_id FROM page_shares s
|
||||
LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=?
|
||||
WHERE s.shared_with_user_id=? OR gm.user_id=?""",
|
||||
(uid, uid, uid),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
made_ids = {r[0] for r in made_rows}
|
||||
recv_ids = {r[0] for r in recv_rows}
|
||||
|
||||
|
||||
+118
-21
@@ -36,18 +36,23 @@ def _slugify(title: str) -> str:
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
"""Invite a user or email to a page."""
|
||||
"""Invite a user, an email, or a group to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
target_group_id = body.get("group_id")
|
||||
email = body.get("email", "")
|
||||
permission = body.get("permission", "view")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
if not target_user_id and not email:
|
||||
raise HTTPException(400, "Provide user_id or email to share with.")
|
||||
if not target_user_id and not target_group_id and not email:
|
||||
raise HTTPException(400, "Provide user_id, group_id or email to share with.")
|
||||
|
||||
# Bridge share permission (view/comment/edit) → granular role
|
||||
# (viewer/commenter/editor) so page_permissions grants stay in sync.
|
||||
_SHARE_TO_ROLE = {"view": "viewer", "comment": "commenter", "edit": "editor"}
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify page exists
|
||||
@@ -61,18 +66,29 @@ async def share_page(page_id: int, request: Request):
|
||||
if not target:
|
||||
raise HTTPException(404, "Target user not found")
|
||||
|
||||
# Verify target group exists if group_id given
|
||||
if target_group_id:
|
||||
gtarget = conn.execute("SELECT id FROM user_groups WHERE id=?", (target_group_id,)).fetchone()
|
||||
if not gtarget:
|
||||
raise HTTPException(404, "Target group not found")
|
||||
|
||||
# Upsert to avoid duplicates: update the existing permission if the same
|
||||
# target (user or email) is already shared on this page.
|
||||
# target (user, group or email) is already shared on this page.
|
||||
target_row = None
|
||||
if target_user_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
||||
(page_id, target_user_id),
|
||||
).fetchone()
|
||||
elif target_group_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_group_id=?",
|
||||
(page_id, target_group_id),
|
||||
).fetchone()
|
||||
elif email:
|
||||
target_row = conn.execute(
|
||||
"""SELECT id FROM page_shares
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""",
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL AND shared_with_group_id IS NULL""",
|
||||
(page_id, email.strip()),
|
||||
).fetchone()
|
||||
|
||||
@@ -85,29 +101,75 @@ async def share_page(page_id: int, request: Request):
|
||||
else:
|
||||
if not email:
|
||||
email = ""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_group_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, target_group_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
except Exception:
|
||||
# Fallback for DBs where the migration has not run yet
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
share_id = cur.lastrowid
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
# ── Mirror group shares into page_permissions so the ACL used by
|
||||
# PermissionManager (can_view/edit/comment) grants real access to
|
||||
# every group member. Best-effort: never break legacy page_shares.
|
||||
if target_group_id:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, target_group_id, _SHARE_TO_ROLE[permission], user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (page=%s group=%s)", page_id, target_group_id)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_group_id": target_group_id,
|
||||
"shared_with_email": email,
|
||||
"permission": permission,
|
||||
"status": "shared",
|
||||
}
|
||||
|
||||
|
||||
def _mirror_share_grant(conn, page_id: int, group_id: int, role: str, granted_by: int) -> None:
|
||||
"""Upsert a ``page_permissions`` grant mirroring a group ``page_shares`` row.
|
||||
|
||||
Keeps the granular ACL (used by ``PermissionManager``) in sync with what
|
||||
the share dialog shows, so invited groups get effective view/edit rights.
|
||||
"""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE page_permissions SET role=?, granted_by=? WHERE id=?",
|
||||
(role, granted_by, existing["id"]))
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO page_permissions (page_id, user_id, group_id, role, granted_by) "
|
||||
"VALUES (?, NULL, ?, ?, ?)",
|
||||
(page_id, group_id, role, granted_by),
|
||||
)
|
||||
|
||||
|
||||
def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
"""Remove the mirrored grant when a group share is updated away or deleted."""
|
||||
conn.execute(
|
||||
"DELETE FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
_require_auth(request)
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
@@ -117,7 +179,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
@@ -127,6 +189,18 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
"UPDATE page_shares SET permission=? WHERE id=?",
|
||||
(permission, share_id),
|
||||
)
|
||||
# Keep the mirrored ACL grant in sync for group shares.
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, gid,
|
||||
{"view": "viewer", "comment": "commenter", "edit": "editor"}[permission],
|
||||
user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (share=%s)", share_id)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated", "share_id": share_id, "permission": permission}
|
||||
@@ -139,13 +213,22 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_revoke(conn, page_id, gid)
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions revoke failed (share=%s)", share_id)
|
||||
# If no more shares, unset is_shared
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||
@@ -167,14 +250,25 @@ async def list_shares(page_id: int, request: Request):
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url, g.name AS group_name
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
LEFT JOIN user_groups g ON s.shared_with_group_id = g.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -182,6 +276,7 @@ async def list_shares(page_id: int, request: Request):
|
||||
{
|
||||
"id": r["id"],
|
||||
"shared_with_user_id": r["shared_with_user_id"],
|
||||
"shared_with_group_id": r["shared_with_group_id"] if "shared_with_group_id" in r.keys() else None,
|
||||
"shared_with_email": r["shared_with_email"],
|
||||
"permission": r["permission"],
|
||||
"created_at": r["created_at"],
|
||||
@@ -189,6 +284,8 @@ async def list_shares(page_id: int, request: Request):
|
||||
"user_login": r["login"],
|
||||
"user_full_name": r["full_name"],
|
||||
"user_avatar_url": r["avatar_url"],
|
||||
"group_name": r["group_name"] if "group_name" in r.keys() else None,
|
||||
"kind": "group" if (("shared_with_group_id" in r.keys() and r["shared_with_group_id"]) or ("group_name" in r.keys() and r["group_name"])) else "user",
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
|
||||
+28
-10
@@ -1,16 +1,18 @@
|
||||
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync).
|
||||
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync, Bearer v6.4.0).
|
||||
|
||||
Pairs with ``app/services/sync_engine.py``. All routes require an authenticated
|
||||
session (``flowdeck_session`` cookie).
|
||||
Auth: ``Authorization: Bearer <token>`` (scopes ``read`` for delta/status,
|
||||
``write`` for batch). The legacy ``flowdeck_session`` cookie is still accepted
|
||||
as a fallback so the installed PWA/service worker keeps syncing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.api_v2_helpers import get_bearer_user, has_scope
|
||||
from app.services.sync_engine import SyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,7 +22,21 @@ router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
|
||||
_engine = SyncEngine()
|
||||
|
||||
|
||||
def _user(request: Request) -> dict:
|
||||
def _user(request: Request, authorization: str | None = None,
|
||||
*, required_scope: str = "read") -> dict:
|
||||
"""Bearer-first auth with session-cookie fallback (offline.js compat)."""
|
||||
auth = authorization or request.headers.get("authorization") or ""
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
try:
|
||||
user = get_bearer_user(request, authorization)
|
||||
except HTTPException:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired API token")
|
||||
if not has_scope(user.get("_token_scopes"), required_scope):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Insufficient scope. Required: {required_scope}",
|
||||
)
|
||||
return user
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
@@ -32,9 +48,10 @@ async def sync_delta(
|
||||
request: Request,
|
||||
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
|
||||
workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""Pull server-side changes since `since` (for the given workspace)."""
|
||||
user = _user(request)
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
if workspace_id is None:
|
||||
raise HTTPException(status_code=400, detail="workspace_id is required")
|
||||
result = await _engine.get_delta(user["id"], since, workspace_id)
|
||||
@@ -44,9 +61,9 @@ async def sync_delta(
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
async def sync_batch(request: Request):
|
||||
async def sync_batch(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Apply a batch of offline mutations and return per-mutation results."""
|
||||
user = _user(request)
|
||||
user = _user(request, authorization, required_scope="write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -63,9 +80,10 @@ async def sync_batch(request: Request):
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None)):
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||
user = _user(request)
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
if not SyncEngine._can_access(conn, user["id"], workspace_id):
|
||||
|
||||
Reference in New Issue
Block a user