- GitHub integration row ajoutée (🦎 Gitea + 🐙 GitHub) - Badge 'Primary' sur le provider principal (auth_method) - Disconnect masqué pour le provider principal - authMethod, githubLinked, giteaLinked dans Alpine data - loadGithubStatus() + disconnectGithub() methods - Matrice respectée: local→déco OK, gitea→déco github OK, github→déco gitea OK
246 lines
7.8 KiB
Python
246 lines
7.8 KiB
Python
"""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, Request, HTTPException
|
|
|
|
from app.db import get_conn
|
|
from app.auth.session import SessionManager
|
|
|
|
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 or email 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")
|
|
email = body.get("email", "")
|
|
permission = body.get("permission", "view")
|
|
|
|
if permission not in ("view", "comment", "edit"):
|
|
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
|
|
|
if not target_user_id and not email:
|
|
raise HTTPException(400, "Provide user_id or email to share with.")
|
|
|
|
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")
|
|
|
|
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, permission, user["id"]),
|
|
)
|
|
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
|
conn.commit()
|
|
|
|
return {
|
|
"id": cur.lastrowid,
|
|
"page_id": page_id,
|
|
"shared_with_user_id": target_user_id,
|
|
"shared_with_email": email,
|
|
"permission": permission,
|
|
"status": "shared",
|
|
}
|
|
|
|
|
|
@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 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,))
|
|
# 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")
|
|
|
|
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_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"],
|
|
}
|
|
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."""
|
|
user = _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(),
|
|
}
|