"""FlowDeck — Sharing, Publishing & Recents API (v4.0).""" from __future__ import annotations import logging import re import unicodedata from datetime import datetime from fastapi import APIRouter, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn 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() 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() 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() 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.utcnow().isoformat()), ) conn.commit() return { "status": "tracked", "user_id": user["id"], "page_id": page_id, "accessed_at": datetime.utcnow().isoformat(), }