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:
@@ -68,6 +68,10 @@ class Settings(BaseSettings):
|
|||||||
reminders_enabled: bool = True
|
reminders_enabled: bool = True
|
||||||
reminder_scan_interval_seconds: int = 60
|
reminder_scan_interval_seconds: int = 60
|
||||||
|
|
||||||
|
# Webhooks outbound (v6.4.0) — retry of failed deliveries
|
||||||
|
webhook_retry_enabled: bool = True
|
||||||
|
webhook_retry_interval_seconds: int = 60
|
||||||
|
|
||||||
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
|
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
|
||||||
# email notifications are skipped (only in-app notifications are delivered).
|
# email notifications are skipped (only in-app notifications are delivered).
|
||||||
smtp_host: str = ""
|
smtp_host: str = ""
|
||||||
|
|||||||
@@ -442,12 +442,18 @@ def init_db():
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
shared_with_user_id INTEGER REFERENCES users(id),
|
shared_with_user_id INTEGER REFERENCES users(id),
|
||||||
|
shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
shared_with_email TEXT DEFAULT '',
|
shared_with_email TEXT DEFAULT '',
|
||||||
permission TEXT NOT NULL DEFAULT 'view',
|
permission TEXT NOT NULL DEFAULT 'view',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INTEGER REFERENCES users(id)
|
created_by INTEGER REFERENCES users(id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
# v5.x: migration — partage par groupes (colonne manquante sur DB existantes)
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE page_shares ADD COLUMN shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
# 4) recents table
|
# 4) recents table
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS recents (
|
CREATE TABLE IF NOT EXISTS recents (
|
||||||
|
|||||||
+11
-2
@@ -98,13 +98,22 @@ async def lifespan(_app: FastAPI):
|
|||||||
from app.services.reminders import reminder_scheduler
|
from app.services.reminders import reminder_scheduler
|
||||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||||
|
|
||||||
|
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||||
|
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||||
|
webhook_task = None
|
||||||
|
if settings.webhook_retry_enabled:
|
||||||
|
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||||
|
|
||||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
|
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
|
||||||
|
if webhook_task is not None:
|
||||||
|
_tasks = _tasks + (webhook_task,)
|
||||||
|
for task in _tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
|
for task in _tasks:
|
||||||
try:
|
try:
|
||||||
await task
|
await task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
@@ -904,6 +904,25 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_idemp_user ON idempotency_keys(user_id, created_at)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_idemp_user ON idempotency_keys(user_id, created_at)")
|
||||||
|
|
||||||
|
|
||||||
|
@register(21, "v6.4.0: webhooks prod — event, retry ledger")
|
||||||
|
def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||||
|
"""v6.4.0 — Webhooks v2 production.
|
||||||
|
|
||||||
|
``webhook_deliveries`` gains ``event`` (which event was delivered) and
|
||||||
|
``next_retry_at`` (epoch seconds; picked up by the retry scheduler).
|
||||||
|
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||||
|
``superseded`` (a retry row replaced this attempt).
|
||||||
|
"""
|
||||||
|
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||||
|
if "event" not in _cols:
|
||||||
|
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||||
|
if "next_retry_at" not in _cols:
|
||||||
|
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN next_retry_at REAL")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_wd_retry ON webhook_deliveries(status, next_retry_at)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register(17, "v6.0.0: sync_version columns")
|
@register(17, "v6.0.0: sync_version columns")
|
||||||
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||||
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
||||||
|
|||||||
+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()
|
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]}
|
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")
|
@router.get("/webhooks")
|
||||||
async def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)):
|
async def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||||
user = get_bearer_user(request, authorization)
|
user = get_bearer_user(request, authorization)
|
||||||
_v2_rate_check(request, user)
|
_v2_rate_check(request, user)
|
||||||
|
limit, offset = parse_pagination(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"]
|
||||||
return {"webhooks": [dict(r) for r in rows]}
|
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")
|
@router.post("/webhooks")
|
||||||
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
|
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()
|
body = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
body = {}
|
body = {}
|
||||||
|
from app.services.webhook_outbound import EVENTS, _event_matches
|
||||||
url = (body.get("url") or "").strip()
|
url = (body.get("url") or "").strip()
|
||||||
event = (body.get("event") or "page.created").strip()
|
event = (body.get("event") or "page.created").strip()
|
||||||
secret = (body.get("secret") or "").strip()
|
secret = (body.get("secret") or "").strip()
|
||||||
if not url or not url.startswith("http"):
|
if not url or not url.startswith("http"):
|
||||||
raise HTTPException(400, "url must start with 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:
|
with get_conn() as conn:
|
||||||
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
|
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
|
||||||
wid = cur.lastrowid
|
wid = cur.lastrowid
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
|
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
|
||||||
audit_log(user, "webhook.create", "webhook", wid, url, request)
|
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}")
|
@router.patch("/webhooks/{webhook_id}")
|
||||||
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
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()
|
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Webhook not found")
|
raise HTTPException(404, "Webhook not found")
|
||||||
# fire test ping (best-effort, record delivery)
|
# live delivery via the prod dispatcher (HMAC + retry + journal),
|
||||||
try:
|
# direct to this subscription only (no wildcard fan-out)
|
||||||
from app.services.webhook_outbound import fire_event
|
from app.services.webhook_outbound import deliver_to_sub
|
||||||
await fire_event("ping", {"webhook_id": webhook_id, "test": True})
|
ok = await deliver_to_sub(webhook_id, row["url"], "ping",
|
||||||
conn.execute("INSERT INTO webhook_deliveries (webhook_id, status, http_code, payload) VALUES (?, 'delivered', 200, ?)", (webhook_id, json.dumps({"event": "ping"})))
|
{"webhook_id": webhook_id, "test": True},
|
||||||
conn.commit()
|
row["secret"] or "")
|
||||||
except Exception as e:
|
audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request)
|
||||||
try:
|
return {"webhook_id": webhook_id, "status": "tested", "delivered": ok}
|
||||||
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"}
|
|
||||||
|
|
||||||
@router.get("/webhooks/{webhook_id}/deliveries")
|
@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)
|
user = get_bearer_user(request, authorization)
|
||||||
_v2_rate_check(request, user)
|
_v2_rate_check(request, user)
|
||||||
|
limit, offset = parse_pagination(request)
|
||||||
with get_conn() as conn:
|
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()
|
if status:
|
||||||
return {"deliveries": [row_to_dict(r) for r in rows]}
|
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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -612,6 +612,15 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
|||||||
f"ORDER BY updated_at DESC LIMIT 20",
|
f"ORDER BY updated_at DESC LIMIT 20",
|
||||||
scope_params,
|
scope_params,
|
||||||
).fetchall()
|
).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(
|
received_rows = conn.execute(
|
||||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||||
"JOIN pages p ON p.id=s.page_id "
|
"JOIN pages p ON p.id=s.page_id "
|
||||||
|
|||||||
@@ -235,11 +235,20 @@ async def library_shared(
|
|||||||
uid = _get_user_id(request)
|
uid = _get_user_id(request)
|
||||||
|
|
||||||
# Page ids the user shares toward others (nominal page_shares) or receives
|
# Page ids the user shares toward others (nominal page_shares) or receives
|
||||||
|
# (direct shares + group shares via group_members)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
made_rows = conn.execute(
|
made_rows = conn.execute(
|
||||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
||||||
(uid,),
|
(uid,),
|
||||||
).fetchall()
|
).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(
|
recv_rows = conn.execute(
|
||||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||||
(uid,),
|
(uid,),
|
||||||
|
|||||||
+105
-8
@@ -36,18 +36,23 @@ def _slugify(title: str) -> str:
|
|||||||
|
|
||||||
@router.post("/pages/{page_id}/share")
|
@router.post("/pages/{page_id}/share")
|
||||||
async def share_page(page_id: int, request: Request):
|
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)
|
user = _require_auth(request)
|
||||||
body = await request.json() if request.headers.get("content-type") else {}
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
target_user_id = body.get("user_id")
|
target_user_id = body.get("user_id")
|
||||||
|
target_group_id = body.get("group_id")
|
||||||
email = body.get("email", "")
|
email = body.get("email", "")
|
||||||
permission = body.get("permission", "view")
|
permission = body.get("permission", "view")
|
||||||
|
|
||||||
if permission not in ("view", "comment", "edit"):
|
if permission not in ("view", "comment", "edit"):
|
||||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||||
|
|
||||||
if not target_user_id and not email:
|
if not target_user_id and not target_group_id and not email:
|
||||||
raise HTTPException(400, "Provide user_id or email to share with.")
|
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:
|
with get_conn() as conn:
|
||||||
# Verify page exists
|
# Verify page exists
|
||||||
@@ -61,18 +66,29 @@ async def share_page(page_id: int, request: Request):
|
|||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(404, "Target user not found")
|
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
|
# 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
|
target_row = None
|
||||||
if target_user_id:
|
if target_user_id:
|
||||||
target_row = conn.execute(
|
target_row = conn.execute(
|
||||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
||||||
(page_id, target_user_id),
|
(page_id, target_user_id),
|
||||||
).fetchone()
|
).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:
|
elif email:
|
||||||
target_row = conn.execute(
|
target_row = conn.execute(
|
||||||
"""SELECT id FROM page_shares
|
"""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()),
|
(page_id, email.strip()),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
@@ -85,6 +101,14 @@ async def share_page(page_id: int, request: Request):
|
|||||||
else:
|
else:
|
||||||
if not email:
|
if not email:
|
||||||
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(
|
cur = conn.execute(
|
||||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
@@ -92,22 +116,60 @@ async def share_page(page_id: int, request: Request):
|
|||||||
)
|
)
|
||||||
share_id = cur.lastrowid
|
share_id = cur.lastrowid
|
||||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
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()
|
conn.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": share_id,
|
"id": share_id,
|
||||||
"page_id": page_id,
|
"page_id": page_id,
|
||||||
"shared_with_user_id": target_user_id,
|
"shared_with_user_id": target_user_id,
|
||||||
|
"shared_with_group_id": target_group_id,
|
||||||
"shared_with_email": email,
|
"shared_with_email": email,
|
||||||
"permission": permission,
|
"permission": permission,
|
||||||
"status": "shared",
|
"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.")
|
@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):
|
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||||
"""Change the permission level of an existing share entry."""
|
"""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 {}
|
body = await request.json() if request.headers.get("content-type") else {}
|
||||||
permission = body.get("permission", "")
|
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:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
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),
|
(share_id, page_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
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=?",
|
"UPDATE page_shares SET permission=? WHERE id=?",
|
||||||
(permission, share_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()
|
conn.commit()
|
||||||
|
|
||||||
return {"status": "updated", "share_id": share_id, "permission": permission}
|
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:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
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),
|
(share_id, page_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Share entry not found")
|
raise HTTPException(404, "Share entry not found")
|
||||||
|
|
||||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
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
|
# If no more shares, unset is_shared
|
||||||
remaining = conn.execute(
|
remaining = conn.execute(
|
||||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||||
@@ -167,6 +250,17 @@ async def list_shares(page_id: int, request: Request):
|
|||||||
if not page:
|
if not page:
|
||||||
raise HTTPException(404, "Page not found")
|
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(
|
rows = conn.execute(
|
||||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||||
FROM page_shares s
|
FROM page_shares s
|
||||||
@@ -182,6 +276,7 @@ async def list_shares(page_id: int, request: Request):
|
|||||||
{
|
{
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"shared_with_user_id": r["shared_with_user_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"],
|
"shared_with_email": r["shared_with_email"],
|
||||||
"permission": r["permission"],
|
"permission": r["permission"],
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
@@ -189,6 +284,8 @@ async def list_shares(page_id: int, request: Request):
|
|||||||
"user_login": r["login"],
|
"user_login": r["login"],
|
||||||
"user_full_name": r["full_name"],
|
"user_full_name": r["full_name"],
|
||||||
"user_avatar_url": r["avatar_url"],
|
"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
|
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
|
Auth: ``Authorization: Bearer <token>`` (scopes ``read`` for delta/status,
|
||||||
session (``flowdeck_session`` cookie).
|
``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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
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
|
from app.services.sync_engine import SyncEngine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,7 +22,21 @@ router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
|
|||||||
_engine = SyncEngine()
|
_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", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
@@ -32,9 +48,10 @@ async def sync_delta(
|
|||||||
request: Request,
|
request: Request,
|
||||||
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
|
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
|
||||||
workspace_id: int = Query(default=None),
|
workspace_id: int = Query(default=None),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
):
|
):
|
||||||
"""Pull server-side changes since `since` (for the given workspace)."""
|
"""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:
|
if workspace_id is None:
|
||||||
raise HTTPException(status_code=400, detail="workspace_id is required")
|
raise HTTPException(status_code=400, detail="workspace_id is required")
|
||||||
result = await _engine.get_delta(user["id"], since, workspace_id)
|
result = await _engine.get_delta(user["id"], since, workspace_id)
|
||||||
@@ -44,9 +61,9 @@ async def sync_delta(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
@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."""
|
"""Apply a batch of offline mutations and return per-mutation results."""
|
||||||
user = _user(request)
|
user = _user(request, authorization, required_scope="write")
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -63,9 +80,10 @@ async def sync_batch(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@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)."""
|
"""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
|
from app.db import get_conn
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
if not SyncEngine._can_access(conn, user["id"], workspace_id):
|
if not SyncEngine._can_access(conn, user["id"], workspace_id):
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
|
"""FlowDeck — Webhook outbound dispatcher (v2.1.0, prod v6.4.0).
|
||||||
|
|
||||||
|
Production-grade delivery: HMAC-SHA256 signature (``X-FlowDeck-Signature``),
|
||||||
|
retry with backoff 2s/10s/60s, full delivery journal in ``webhook_deliveries``.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -9,29 +18,227 @@ from app.db import get_conn
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Event catalogue (≈28 events) ────────────────────────────────────────────
|
||||||
EVENTS = [
|
EVENTS = [
|
||||||
"page.created", "page.updated", "page.deleted",
|
# pages (block-editor documents)
|
||||||
|
"page.created", "page.updated", "page.deleted", "page.moved",
|
||||||
|
"page.restored", "page.locked", "page.unlocked",
|
||||||
|
"page.shared", "page.published", "page.unpublished",
|
||||||
|
# comments & collaboration
|
||||||
|
"comment.added", "comment.resolved", "mention.added",
|
||||||
|
# collections (databases)
|
||||||
"collection.created", "collection.updated", "collection.deleted",
|
"collection.created", "collection.updated", "collection.deleted",
|
||||||
"comment.added", "page.moved",
|
"collection.page.created", "collection.page.updated", "collection.page.deleted",
|
||||||
|
"collection.view.created",
|
||||||
|
# sprints / tasks
|
||||||
|
"sprint.created", "sprint.updated",
|
||||||
|
# sharing / favorites
|
||||||
|
"favorite.added", "favorite.removed",
|
||||||
|
# automations & agent
|
||||||
|
"automation.fired", "agent.run.finished",
|
||||||
|
# generic
|
||||||
|
"ping",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Retry schedule: delays in seconds between attempts (attempt 0 = immediate).
|
||||||
|
RETRY_DELAYS = (2, 10, 60)
|
||||||
|
MAX_ATTEMPTS = len(RETRY_DELAYS) + 1 # 1 initial + 3 retries
|
||||||
|
|
||||||
|
|
||||||
|
def sign_payload(secret: str, body: bytes) -> str:
|
||||||
|
"""HMAC-SHA256 hex signature of the raw JSON body (``sha256=<hex>``)."""
|
||||||
|
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(secret: str, body: bytes, signature: str) -> bool:
|
||||||
|
"""Constant-time check of an ``X-FlowDeck-Signature`` header value."""
|
||||||
|
if not secret or not signature:
|
||||||
|
return False
|
||||||
|
expected = sign_payload(secret, body)
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_matches(sub_event: str, event: str) -> bool:
|
||||||
|
"""Wildcard matching: ``*`` matches all, ``page.*`` matches page.* events."""
|
||||||
|
if sub_event in ("*", "all"):
|
||||||
|
return True
|
||||||
|
if sub_event.endswith(".*"):
|
||||||
|
return event.startswith(sub_event[:-1])
|
||||||
|
return sub_event == event
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_subs(conn, event: str) -> list:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, url, event, secret FROM webhook_subscriptions WHERE active=1"
|
||||||
|
).fetchall()
|
||||||
|
return [r for r in rows if _event_matches((r["event"] or "").strip(), event)]
|
||||||
|
|
||||||
|
|
||||||
|
def _log_delivery(webhook_id: int, event: str, status: str, http_code: int | None,
|
||||||
|
error: str, duration_ms: int, attempt: int, payload: dict,
|
||||||
|
next_retry_at: float | None = None) -> None:
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO webhook_deliveries
|
||||||
|
(webhook_id, event, status, http_code, error, duration_ms,
|
||||||
|
attempt, payload, next_retry_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(webhook_id, event, status,
|
||||||
|
http_code if http_code is not None else 0,
|
||||||
|
(error or "")[:1000], duration_ms, attempt,
|
||||||
|
json.dumps(payload)[:8000],
|
||||||
|
next_retry_at),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception: # noqa: BLE001 — logging must never break delivery
|
||||||
|
logger.debug("Failed to log webhook delivery", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_once(client: httpx.AsyncClient, url: str, event: str,
|
||||||
|
payload: dict, secret: str) -> tuple[int | None, str]:
|
||||||
|
"""Single POST attempt. Returns (http_code, error)."""
|
||||||
|
body = json.dumps({"event": event, **payload}).encode()
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-FlowDeck-Event": event,
|
||||||
|
"User-Agent": "FlowDeck-Webhooks/1.0",
|
||||||
|
}
|
||||||
|
if secret:
|
||||||
|
headers["X-FlowDeck-Signature"] = sign_payload(secret, body)
|
||||||
|
# legacy header kept for backward compatibility
|
||||||
|
headers["X-FlowDeck-Secret"] = secret
|
||||||
|
try:
|
||||||
|
resp = await client.post(url, content=body, headers=headers)
|
||||||
|
if 200 <= resp.status_code < 300:
|
||||||
|
return resp.status_code, ""
|
||||||
|
return resp.status_code, f"HTTP {resp.status_code}"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, str(exc)[:500]
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver_to_sub(sub_id: int, url: str, event: str, payload: dict,
|
||||||
|
secret: str, *, _client: httpx.AsyncClient | None = None) -> bool:
|
||||||
|
"""Deliver with retry (immediate + 2s/10s/60s). Logs every attempt.
|
||||||
|
|
||||||
|
Returns True on success. Failures are re-queued via ``next_retry_at`` so
|
||||||
|
the background scheduler can pick them up even if this process restarts.
|
||||||
|
"""
|
||||||
|
own_client = _client is None
|
||||||
|
client = _client or httpx.AsyncClient(timeout=10)
|
||||||
|
try:
|
||||||
|
for attempt in range(MAX_ATTEMPTS):
|
||||||
|
if attempt > 0:
|
||||||
|
await asyncio.sleep(RETRY_DELAYS[attempt - 1])
|
||||||
|
start = time.monotonic()
|
||||||
|
code, err = await _deliver_once(client, url, event, payload, secret or "")
|
||||||
|
duration = int((time.monotonic() - start) * 1000)
|
||||||
|
ok = code is not None and 200 <= code < 300
|
||||||
|
last = attempt == MAX_ATTEMPTS - 1
|
||||||
|
if ok or last:
|
||||||
|
_log_delivery(sub_id, event, "delivered" if ok else "failed",
|
||||||
|
code, err, duration, attempt, payload)
|
||||||
|
return ok
|
||||||
|
# schedule retry
|
||||||
|
delay = RETRY_DELAYS[attempt]
|
||||||
|
_log_delivery(sub_id, event, "retrying", code, err, duration,
|
||||||
|
attempt, payload,
|
||||||
|
next_retry_at=time.time() + delay)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def fire_event(event: str, payload: dict):
|
async def fire_event(event: str, payload: dict):
|
||||||
"""Fire a webhook event to all registered subscribers."""
|
"""Fire a webhook event to all registered subscribers (wildcard-aware).
|
||||||
|
|
||||||
|
Unknown events are ignored. Delivery itself never raises: each
|
||||||
|
subscription is attempted independently and journaled.
|
||||||
|
"""
|
||||||
if event not in EVENTS:
|
if event not in EVENTS:
|
||||||
return
|
return
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
subs = conn.execute("SELECT url, secret FROM webhook_subscriptions WHERE event=?", (event,)).fetchall()
|
subs = _matching_subs(conn, event)
|
||||||
|
if not subs:
|
||||||
|
return
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
url, secret = sub["url"], sub["secret"]
|
|
||||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": event}
|
|
||||||
if secret:
|
|
||||||
headers["X-FlowDeck-Secret"] = secret
|
|
||||||
try:
|
try:
|
||||||
await client.post(url, json=payload, headers=headers)
|
await deliver_to_sub(sub["id"], sub["url"], event,
|
||||||
|
dict(payload), sub["secret"] or "",
|
||||||
|
_client=client)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Webhook delivery failed to %s", sub["url"])
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_due_deliveries(now: float | None = None) -> int:
|
||||||
|
"""Re-fire deliveries stuck in ``retrying`` whose ``next_retry_at`` passed.
|
||||||
|
|
||||||
|
Returns the number of deliveries retried. Called by the background
|
||||||
|
scheduler and directly testable.
|
||||||
|
"""
|
||||||
|
now = now if now is not None else time.time()
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT d.id, d.webhook_id, d.event, d.payload, d.attempt,
|
||||||
|
s.url, s.secret
|
||||||
|
FROM webhook_deliveries d
|
||||||
|
JOIN webhook_subscriptions s ON s.id = d.webhook_id
|
||||||
|
WHERE d.status='retrying' AND s.active=1
|
||||||
|
AND d.next_retry_at IS NOT NULL AND d.next_retry_at <= ?
|
||||||
|
ORDER BY d.next_retry_at LIMIT 50""",
|
||||||
|
(now,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(r["payload"] or "{}")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {"data": payload}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Webhook delivery failed to %s", url)
|
payload = {}
|
||||||
|
event = r["event"] or "ping"
|
||||||
|
# resume where the previous run stopped, up to MAX_ATTEMPTS
|
||||||
|
start_attempt = min((r["attempt"] or 0) + 1, MAX_ATTEMPTS - 1)
|
||||||
|
for att in range(start_attempt, MAX_ATTEMPTS):
|
||||||
|
delay = RETRY_DELAYS[att - 1] if att > 0 else 0
|
||||||
|
if delay:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
code, err = await _deliver_once(client, r["url"], event,
|
||||||
|
payload, r["secret"] or "")
|
||||||
|
duration = int((time.monotonic() - t0) * 1000)
|
||||||
|
ok = code is not None and 200 <= code < 300
|
||||||
|
last = att == MAX_ATTEMPTS - 1
|
||||||
|
if ok or last:
|
||||||
|
_log_delivery(r["webhook_id"], event,
|
||||||
|
"delivered" if ok else "failed",
|
||||||
|
code, err, duration, att, payload)
|
||||||
|
break
|
||||||
|
_log_delivery(r["webhook_id"], event, "retrying", code, err,
|
||||||
|
duration, att, payload,
|
||||||
|
next_retry_at=time.time() + RETRY_DELAYS[att])
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE webhook_deliveries SET status='superseded' WHERE id=?", (r["id"],))
|
||||||
|
conn.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def webhook_retry_scheduler(interval_seconds: int = 60) -> None:
|
||||||
|
"""Background loop: retry due webhook deliveries every minute."""
|
||||||
|
from app.config import settings as _settings
|
||||||
|
interval = getattr(_settings, "webhook_retry_interval_seconds", interval_seconds)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
await retry_due_deliveries()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("webhook_retry_scheduler tick failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def init_webhook_tables():
|
def init_webhook_tables():
|
||||||
|
|||||||
@@ -94,7 +94,27 @@
|
|||||||
<div class="sd-user-add">Invite</div>
|
<div class="sd-user-add">Invite</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="sd-user-empty" x-show="!inviteUsers.length">No matching users</div>
|
<template x-if="inviteGroups && inviteGroups.length">
|
||||||
|
<div>
|
||||||
|
<div class="sd-user-empty" style="font-weight:600;">Groups</div>
|
||||||
|
<template x-for="g in inviteGroups" :key="'g'+g.id">
|
||||||
|
<div
|
||||||
|
class="sd-user-item"
|
||||||
|
@click="pickInviteGroup(g)"
|
||||||
|
>
|
||||||
|
<div class="sd-user-avatar" style="background:#5b5bd6;color:#fff;">
|
||||||
|
<span x-text="(g.name || '?').charAt(0).toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
<div class="sd-user-meta">
|
||||||
|
<div class="sd-user-login" x-text="g.name"></div>
|
||||||
|
<div class="sd-user-name" x-text="'Group · ' + (g.member_count || 0) + ' members'"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sd-user-add">Invite</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="sd-user-empty" x-show="!inviteUsers.length && !(inviteGroups && inviteGroups.length)">No matching users</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,17 +133,22 @@
|
|||||||
<div class="sd-avatar" x-show="a.user_avatar_url" style="overflow:hidden">
|
<div class="sd-avatar" x-show="a.user_avatar_url" style="overflow:hidden">
|
||||||
<img :src="a.user_avatar_url" style="width:100%;height:100%;border-radius:50%;object-fit:cover;" />
|
<img :src="a.user_avatar_url" style="width:100%;height:100%;border-radius:50%;object-fit:cover;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="sd-avatar" x-show="!a.user_avatar_url"
|
<!-- Group avatar -->
|
||||||
|
<div class="sd-avatar" x-show="(a.kind === 'group' || a.shared_with_group_id) && !a.user_avatar_url"
|
||||||
|
style="background:#5b5bd6;color:#fff;"
|
||||||
|
x-text="(a.group_name || '?')[0].toUpperCase()"
|
||||||
|
></div>
|
||||||
|
<div class="sd-avatar" x-show="!(a.kind === 'group' || a.shared_with_group_id) && !a.user_avatar_url"
|
||||||
x-text="(a.user_full_name || a.user_login || a.shared_with_email || '?')[0].toUpperCase()"
|
x-text="(a.user_full_name || a.user_login || a.shared_with_email || '?')[0].toUpperCase()"
|
||||||
></div>
|
></div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="sd-participant-name"
|
class="sd-participant-name"
|
||||||
x-text="a.user_full_name || a.user_login || a.shared_with_email"
|
x-text="(a.kind === 'group' || a.shared_with_group_id) ? (a.group_name || 'Group') : (a.user_full_name || a.user_login || a.shared_with_email)"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="sd-participant-email"
|
class="sd-participant-email"
|
||||||
x-text="a.shared_with_email || a.user_login || ''"
|
x-text="(a.kind === 'group' || a.shared_with_group_id) ? 'Group' : (a.shared_with_email || a.user_login || '')"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1023,7 +1023,7 @@ window.__fdEditorScriptsLoaded = true;
|
|||||||
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
||||||
pubAllowEdit:false,pubAllowComments:true,
|
pubAllowEdit:false,pubAllowComments:true,
|
||||||
pageUrl:window.location.href,publishedUrl:'',
|
pageUrl:window.location.href,publishedUrl:'',
|
||||||
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteGroupId:null,inviteGroups:[],inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
||||||
toastVisible:false,toastMsg:'',
|
toastVisible:false,toastMsg:'',
|
||||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||||
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
|
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
|
||||||
@@ -1659,15 +1659,30 @@ applyAIBlocks(text){
|
|||||||
},
|
},
|
||||||
shareInvite() {
|
shareInvite() {
|
||||||
var self = this;
|
var self = this;
|
||||||
if (!this.inviteEmail.trim()) return;
|
if (this.inviteGroupId) {
|
||||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
fetch('/api/pages/' + this.pid + '/share', {
|
fetch('/api/pages/' + this.pid + '/share', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||||
|
body: JSON.stringify({ group_id: this.inviteGroupId, permission: this.invitePermission })
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
if (d.status === 'shared') {
|
||||||
|
self.inviteEmail = ''; self.inviteUserId = null; self.inviteGroupId = null;
|
||||||
|
self.inviteUsers = []; self.inviteGroups = []; self.inviteSuggestOpen = false;
|
||||||
|
self.showToast('Group shared'); self.loadShares();
|
||||||
|
} else { self.showToast(d.detail || 'Share failed'); }
|
||||||
|
}).catch(function(){ self.showToast('Share failed'); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.inviteEmail.trim()) return;
|
||||||
|
var csrf2 = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
fetch('/api/pages/' + this.pid + '/share', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf2 ? csrf2[1] : '' },
|
||||||
body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission })
|
body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||||
}).then(function(r){ return r.json(); }).then(function(d){
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
if (d.status === 'shared') {
|
if (d.status === 'shared') {
|
||||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteSuggestOpen = false;
|
self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteGroups = []; self.inviteSuggestOpen = false;
|
||||||
self.showToast('Invitation sent'); self.loadShares();
|
self.showToast('Invitation sent'); self.loadShares();
|
||||||
} else { self.showToast(d.detail || 'Share failed'); }
|
} else { self.showToast(d.detail || 'Share failed'); }
|
||||||
}).catch(function(){ self.showToast('Share failed'); });
|
}).catch(function(){ self.showToast('Share failed'); });
|
||||||
@@ -1675,7 +1690,7 @@ applyAIBlocks(text){
|
|||||||
inviteInput(){
|
inviteInput(){
|
||||||
var self = this;
|
var self = this;
|
||||||
var q = (this.inviteEmail || '').trim();
|
var q = (this.inviteEmail || '').trim();
|
||||||
if (!q) { this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteSel = -1; return; }
|
if (!q) { this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteGroupId = null; this.inviteSel = -1; return; }
|
||||||
clearTimeout(this._inviteTimer);
|
clearTimeout(this._inviteTimer);
|
||||||
this._inviteTimer = setTimeout(function(){ self._loadInviteUsers(q); }, 150);
|
this._inviteTimer = setTimeout(function(){ self._loadInviteUsers(q); }, 150);
|
||||||
},
|
},
|
||||||
@@ -1689,9 +1704,20 @@ applyAIBlocks(text){
|
|||||||
this.inviteUsers = (d.users || []).filter(function(u){
|
this.inviteUsers = (d.users || []).filter(function(u){
|
||||||
return u.id !== me && !shared[(u.login || '').toLowerCase()];
|
return u.id !== me && !shared[(u.login || '').toLowerCase()];
|
||||||
});
|
});
|
||||||
|
// Groups: charger les groupes du workspace et filtrer
|
||||||
|
try {
|
||||||
|
const gr = await fetch('/api/v2/groups', { credentials: 'same-origin' });
|
||||||
|
const gd = await gr.json();
|
||||||
|
var sharedGroups = {};
|
||||||
|
(this.accessList || []).forEach(function(a){ if (a.shared_with_group_id) sharedGroups[a.shared_with_group_id] = true; });
|
||||||
|
var ql = (q || '').toLowerCase();
|
||||||
|
this.inviteGroups = (gd.groups || []).filter(function(g){
|
||||||
|
return !sharedGroups[g.id] && (!ql || (g.name || '').toLowerCase().indexOf(ql) >= 0);
|
||||||
|
}).slice(0, 8);
|
||||||
|
} catch(e2) { this.inviteGroups = []; }
|
||||||
this.inviteSel = this.inviteUsers.length ? 0 : -1;
|
this.inviteSel = this.inviteUsers.length ? 0 : -1;
|
||||||
this.inviteSuggestOpen = true;
|
this.inviteSuggestOpen = true;
|
||||||
} catch(e) { this.inviteUsers = []; this.inviteSuggestOpen = false; }
|
} catch(e) { this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; }
|
||||||
},
|
},
|
||||||
inviteMove(dir){
|
inviteMove(dir){
|
||||||
var el = this.$refs.inviteSuggest;
|
var el = this.$refs.inviteSuggest;
|
||||||
@@ -1713,8 +1739,17 @@ applyAIBlocks(text){
|
|||||||
},
|
},
|
||||||
pickInviteUser(u){
|
pickInviteUser(u){
|
||||||
this.inviteUserId = u.id;
|
this.inviteUserId = u.id;
|
||||||
|
this.inviteGroupId = null;
|
||||||
this.inviteEmail = u.login || u.full_name || '';
|
this.inviteEmail = u.login || u.full_name || '';
|
||||||
this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||||
|
var el = this.$refs.inviteInput;
|
||||||
|
if (el) el.focus();
|
||||||
|
},
|
||||||
|
pickInviteGroup(g){
|
||||||
|
this.inviteGroupId = g.id;
|
||||||
|
this.inviteUserId = null;
|
||||||
|
this.inviteEmail = g.name || '';
|
||||||
|
this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||||
var el = this.$refs.inviteInput;
|
var el = this.$refs.inviteInput;
|
||||||
if (el) el.focus();
|
if (el) el.focus();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""FlowDeck — Partage de page par groupe (page_shares.shared_with_group_id).
|
||||||
|
|
||||||
|
Couvre : création d'un partage groupe, upsert anti-doublon,
|
||||||
|
listage (kind=group, group_name), mise à jour de permission,
|
||||||
|
suppression, et erreurs 404 (page / groupe inconnu).
|
||||||
|
"""
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
def _token(user_id, login="user", is_admin=1):
|
||||||
|
return SessionManager.create_session(
|
||||||
|
{"id": user_id, "login": login, "full_name": login.title(), "is_admin": is_admin}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_owner():
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE id=1").fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (id, login, full_name, email, is_admin) "
|
||||||
|
"VALUES (1, 'owner', 'Owner', '[email protected]', 1)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _as(client, user_id=1, login="owner"):
|
||||||
|
_ensure_owner()
|
||||||
|
client.cookies.set("flowdeck_session", _token(user_id, login))
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_page(title="Group Share Page"):
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||||
|
"VALUES ('Private', ?, '[]', 'blocks', 'Private')",
|
||||||
|
(title,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_group(name="Team"):
|
||||||
|
_ensure_owner()
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO user_groups (workspace_id, name, description, created_by) "
|
||||||
|
"VALUES (NULL, ?, '', 1)",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_page_with_group(client):
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
gid = _insert_group()
|
||||||
|
r = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "edit"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
d = r.json()
|
||||||
|
assert d["status"] == "shared"
|
||||||
|
assert d["shared_with_group_id"] == gid
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_page_group_upsert_no_duplicate(client):
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
gid = _insert_group()
|
||||||
|
r1 = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"})
|
||||||
|
r2 = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "comment"})
|
||||||
|
assert r1.status_code == 200 and r2.status_code == 200
|
||||||
|
assert r1.json()["id"] == r2.json()["id"]
|
||||||
|
with get_conn() as conn:
|
||||||
|
n = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=? AND shared_with_group_id=?",
|
||||||
|
(pid, gid),
|
||||||
|
).fetchone()["c"]
|
||||||
|
assert n == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_shares_includes_group(client):
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
gid = _insert_group(name="Editors")
|
||||||
|
client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"})
|
||||||
|
r = client.get(f"/api/pages/{pid}/shares")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
shares = r.json()["shares"]
|
||||||
|
g = [s for s in shares if s.get("shared_with_group_id") == gid]
|
||||||
|
assert len(g) == 1
|
||||||
|
assert g[0]["kind"] == "group"
|
||||||
|
assert g[0]["group_name"] == "Editors"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_group_share_permission(client):
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
gid = _insert_group()
|
||||||
|
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"}).json()["id"]
|
||||||
|
r = client.put(f"/api/pages/{pid}/share/{sid}", json={"permission": "edit"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["permission"] == "edit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_group_share(client):
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
gid = _insert_group()
|
||||||
|
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"}).json()["id"]
|
||||||
|
r = client.delete(f"/api/pages/{pid}/share/{sid}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT id FROM page_shares WHERE id=?", (sid,)).fetchone()
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_unknown_group_404(client):
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
r = client.post(f"/api/pages/{pid}/share", json={"group_id": 999999, "permission": "view"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_share_needs_target_400(client):
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
r = client.post(f"/api/pages/{pid}/share", json={"permission": "view"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_share_grants_effective_access(client):
|
||||||
|
"""A member of an invited group sees the page via page_permissions mirror."""
|
||||||
|
from app.services.permission_manager import PermissionManager
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
ws = _insert_workspace(1)
|
||||||
|
_bind_page_workspace(pid, ws)
|
||||||
|
gid = _insert_group(name="Readers")
|
||||||
|
member = _insert_user("reader", "[email protected]", "Reader")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", (gid, member))
|
||||||
|
conn.commit()
|
||||||
|
r = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "edit"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert PermissionManager(member).can_edit_page(pid)
|
||||||
|
assert PermissionManager(member).can_view_page(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_share_update_syncs_grant(client):
|
||||||
|
from app.services.permission_manager import PermissionManager
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
ws = _insert_workspace(1)
|
||||||
|
_bind_page_workspace(pid, ws)
|
||||||
|
gid = _insert_group(name="Writers")
|
||||||
|
member = _insert_user("writer", "[email protected]", "Writer")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", (gid, member))
|
||||||
|
conn.commit()
|
||||||
|
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "view"}).json()["id"]
|
||||||
|
assert PermissionManager(member).can_view_page(pid)
|
||||||
|
assert not PermissionManager(member).can_edit_page(pid)
|
||||||
|
client.put(f"/api/pages/{pid}/share/{sid}", json={"permission": "edit"})
|
||||||
|
assert PermissionManager(member).can_edit_page(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_share_remove_revokes_grant(client):
|
||||||
|
from app.services.permission_manager import PermissionManager
|
||||||
|
_as(client)
|
||||||
|
pid = _insert_page()
|
||||||
|
ws = _insert_workspace(1)
|
||||||
|
_bind_page_workspace(pid, ws)
|
||||||
|
gid = _insert_group(name="Temp")
|
||||||
|
member = _insert_user("temp", "[email protected]", "Temp")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT INTO group_members (group_id, user_id) VALUES (?, ?)", (gid, member))
|
||||||
|
conn.commit()
|
||||||
|
sid = client.post(f"/api/pages/{pid}/share", json={"group_id": gid, "permission": "edit"}).json()["id"]
|
||||||
|
assert PermissionManager(member).can_edit_page(pid)
|
||||||
|
client.delete(f"/api/pages/{pid}/share/{sid}")
|
||||||
|
assert not PermissionManager(member).can_view_page(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_workspace(owner_id, name="WS"):
|
||||||
|
_ensure_owner()
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", (name, owner_id))
|
||||||
|
conn.commit()
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_page_workspace(page_id, workspace_id):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET workspace_id=?, permission_type='restricted' WHERE id=?",
|
||||||
|
(workspace_id, page_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_user(login, email, full_name):
|
||||||
|
_ensure_owner()
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO users (login, email, full_name, is_active) VALUES (?,?,?,1)",
|
||||||
|
(login, email, full_name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.lastrowid
|
||||||
Reference in New Issue
Block a user