Files
flowdeck/app/routers/sharing.py
T
bruno cb47f5c7f4
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A26 + A33 + A34 + A35 + A36 + A43 — bande S/XS du P2 (v7.3.9)
- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne
  produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus
  PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut
- A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` +
  non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests`
  (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un
  proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié
- A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent
  après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning
- A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README
  (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ;
  le drift Python 3.12/3.13 reste noté (rebuild d'image à valider)
- A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi,
  loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la
  CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources
- A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO
  naïf identique, warnings de dépréciation divisés : 2374 → 926)

suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
2026-10-01 08:50:39 -04:00

412 lines
15 KiB
Python

"""FlowDeck — Sharing, Publishing & Recents API (v4.0)."""
from __future__ import annotations
import logging
import re
import unicodedata
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event as _fire_event
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
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a page title."""
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
return slug or "untitled"
# ── Page Sharing ──
@router.post("/pages/{page_id}/share")
async def share_page(page_id: int, request: Request):
"""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 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:
await _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.")
async def update_share_permission(page_id: int, share_id: int, request: Request):
"""Change the permission level of an existing share entry."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
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}")
async 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")
async 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")
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
slug = _slugify(page["title"])
# Ensure uniqueness by appending suffix if needed
base_slug = slug
counter = 1
while conn.execute(
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
).fetchone():
slug = f"{base_slug}-{counter}"
counter += 1
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
(slug, page_id),
)
conn.commit()
try:
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
except Exception:
logger.exception("publish_page")
return {
"page_id": page_id,
"is_published": True,
"publish_slug": slug,
"status": "published",
}
@router.delete("/pages/{page_id}/publish")
async def unpublish_page(page_id: int, request: Request):
"""Unpublish a page."""
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
try:
await _fire_event("page.unpublished", {"page_id": page_id})
except Exception:
logger.exception("unpublish_page")
return {
"page_id": page_id,
"is_published": False,
"status": "unpublished",
}
# ── Recents ──
@router.post("/recents/track")
async def track_recent(request: Request):
"""Record a page access in recents."""
user = _require_auth(request)
body = await request.json() if request.headers.get("content-type") else {}
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(),
}