4 passes (283 → 93 routes async sur 667 = 86 % hors loop, avant 61 %) :
A. RACINE AUTH — `get_current_user` (auth/session.py) était `async def`
SANS aucun await (cookie decode = synchrone) ; idem ses clones :
`agent._current_user_id/_workspace_id/_current_admin` (34 sites) et
`sso._require_admin` (corps 0 await, 6 sites) → `def` +
47 `await` supprimés. Piège : 3 call sites passaient par l'alias `gcu`
(grep littéral aveugle) — 8 tests en échec → corrigés.
B. Re-scan : 19 routes devenues SANS await → `def` (agent 8, sso 5,
web_clipper 3, projects 2, auth 1…).
C/D. 155 routes dont les seuls awaits = `request.json()` / événements :
- try/except `body = {}` → `Body(default={})` (même tolérance)
- try/except `raise HTTPException(400)` → `Body(...)` REQUIS
(422 FastAPI — aucun test ne couvrait le 400)
- forme conditionnelle `request.json() if content-type else {}`
(54 sites) → défaut `{}` (sans corps = `{}` dans les 2 cas)
- `await fire_*` → `run_event_sync(...)` ; imports `Body` /
`run_event_sync` ajoutés aux routers convertis
Reste async (93, justifié) : form/upload/file (22), réseau gitea/llm/oidc,
`_json_body` (9), 2 JSON inline en argument, 1 fallback logique
(capture_frontend_error), 1 lecture conditionnelle (web_clipper), mixtes.
suite **1089/1089** · ruff OK · docs à jour
357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""FlowDeck — Sharing, Publishing & Recents API (v4.0)."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services.automations import fire_event as _fire_event
|
|
from app.services.automations import run_event_sync
|
|
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["sharing"], prefix="/api")
|
|
|
|
|
|
def _require_auth(request: Request) -> dict:
|
|
"""Require an authenticated session. Returns user dict or raises 401."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
return user
|
|
|
|
|
|
# ── Page Sharing ──
|
|
|
|
|
|
@router.post("/pages/{page_id}/share")
|
|
def share_page(page_id: int, request: Request, body: dict = Body(default={})):
|
|
"""Invite a user, an email, or a group to a page."""
|
|
user = _require_auth(request)
|
|
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 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
|
|
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not page:
|
|
raise HTTPException(404, "Page not found")
|
|
|
|
# Verify target user exists if user_id given
|
|
if target_user_id:
|
|
target = conn.execute("SELECT id FROM users WHERE id=?", (target_user_id,)).fetchone()
|
|
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, 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 AND shared_with_group_id IS NULL""",
|
|
(page_id, email.strip()),
|
|
).fetchone()
|
|
|
|
if target_row:
|
|
conn.execute(
|
|
"UPDATE page_shares SET permission=?, created_by=? WHERE id=?",
|
|
(permission, user["id"], target_row["id"]),
|
|
)
|
|
share_id = target_row["id"]
|
|
else:
|
|
if not email:
|
|
email = ""
|
|
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()
|
|
|
|
try:
|
|
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}))
|
|
except Exception:
|
|
logger.exception("share_page")
|
|
|
|
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.")
|
|
def update_share_permission(page_id: int, share_id: int, request: Request, body: dict = Body(default={})):
|
|
"""Change the permission level of an existing share entry."""
|
|
user = _require_auth(request)
|
|
|
|
permission = body.get("permission", "")
|
|
|
|
if permission not in ("view", "comment", "edit"):
|
|
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"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(
|
|
"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}
|
|
|
|
|
|
@router.delete("/pages/{page_id}/share/{share_id}")
|
|
def remove_share(page_id: int, share_id: int, request: Request):
|
|
"""Remove a share invitation."""
|
|
_require_auth(request)
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"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,)
|
|
).fetchone()["c"]
|
|
if remaining == 0:
|
|
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (page_id,))
|
|
conn.commit()
|
|
|
|
return {"status": "removed", "share_id": share_id}
|
|
|
|
|
|
@router.get("/pages/{page_id}/shares")
|
|
def list_shares(page_id: int, request: Request):
|
|
"""Get all shares for a page."""
|
|
_require_auth(request)
|
|
|
|
with get_conn() as conn:
|
|
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not page:
|
|
raise HTTPException(404, "Page not found")
|
|
|
|
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,
|
|
"shares": [
|
|
{
|
|
"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"],
|
|
"created_by": r["created_by"],
|
|
"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
|
|
],
|
|
}
|
|
|
|
|
|
# ── Page Publishing ──
|
|
|
|
|
|
@router.post("/pages/{page_id}/publish")
|
|
def publish_page(page_id: int, request: Request):
|
|
"""Publish a page (is_published=1) with a URL slug."""
|
|
_require_auth(request)
|
|
slug, _title = publish(page_id)
|
|
run_event_sync(fire_published(page_id, slug))
|
|
return {
|
|
"page_id": page_id,
|
|
"is_published": True,
|
|
"publish_slug": slug,
|
|
"status": "published",
|
|
}
|
|
|
|
|
|
@router.delete("/pages/{page_id}/publish")
|
|
def unpublish_page(page_id: int, request: Request):
|
|
"""Unpublish a page."""
|
|
_require_auth(request)
|
|
unpublish(page_id)
|
|
run_event_sync(fire_unpublished(page_id))
|
|
return {
|
|
"page_id": page_id,
|
|
"is_published": False,
|
|
"status": "unpublished",
|
|
}
|
|
|
|
|
|
# ── Recents ──
|
|
|
|
|
|
@router.post("/recents/track")
|
|
def track_recent(request: Request, body: dict = Body(default={})):
|
|
"""Record a page access in recents."""
|
|
user = _require_auth(request)
|
|
page_id = body.get("page_id")
|
|
workspace = body.get("workspace", "")
|
|
source_type = body.get("source_type", "local")
|
|
|
|
if not page_id:
|
|
raise HTTPException(400, "page_id is required")
|
|
|
|
with get_conn() as conn:
|
|
page = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not page:
|
|
raise HTTPException(404, "Page not found")
|
|
|
|
conn.execute(
|
|
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, page_id)
|
|
DO UPDATE SET workspace=excluded.workspace,
|
|
source_type=excluded.source_type,
|
|
accessed_at=excluded.accessed_at""",
|
|
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
|
)
|
|
conn.commit()
|
|
|
|
return {
|
|
"status": "tracked",
|
|
"user_id": user["id"],
|
|
"page_id": page_id,
|
|
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
|
}
|