Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
Clear filters), toasts sur chaque action.
Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
/board/api/pages/{id}/trash (inexistante) alors que la route réelle est
/api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
réécrivait parent_section='Trash' et la restauration ne le remettait pas,
alors que tous les listings filtrent parent_section != 'Trash'. Écriture
retirée (deleted_at = source de vérité unique) + réparation idempotente au
boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
POST /api/pages/{id}/trash — vérifié anonyme → 401.
Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
217 lines
7.5 KiB
Python
217 lines
7.5 KiB
Python
"""FlowDeck — Board : sharing.
|
|
|
|
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services.automations import fire_event, run_event_sync
|
|
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
|
|
|
from ._common import _sidebar_data
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["board"], prefix="/board")
|
|
|
|
|
|
|
|
# ═══════════ Favorites API ═══════════
|
|
|
|
@router.get("/api/favorites")
|
|
def list_favorites(request: Request):
|
|
"""List favorited page IDs for the current user."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
|
|
).fetchall()
|
|
return {"favorites": [r["page_id"] for r in rows]}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/favorites/{page_id:int}")
|
|
def add_favorite(request: Request, page_id: int):
|
|
"""Add a page to favorites."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
existing = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
|
|
).fetchone()
|
|
if not existing:
|
|
pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
|
|
).fetchone()[0]
|
|
conn.execute(
|
|
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
|
|
(uid, page_id, pos),
|
|
)
|
|
conn.commit()
|
|
try:
|
|
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
|
except Exception:
|
|
logger.exception("add_favorite")
|
|
return {"status": "added", "page_id": page_id}
|
|
|
|
|
|
|
|
|
|
@router.delete("/api/favorites/{page_id:int}")
|
|
def remove_favorite(request: Request, page_id: int):
|
|
"""Remove a page from favorites."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
|
conn.commit()
|
|
try:
|
|
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
|
|
except Exception:
|
|
logger.exception("remove_favorite")
|
|
return {"status": "removed", "page_id": page_id}
|
|
|
|
# ═══════════ Share API ═══════════
|
|
|
|
|
|
|
|
# ═══════════ Share API ═══════════
|
|
|
|
@router.post("/api/share/{page_id:int}")
|
|
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
|
"""Save share settings for a page."""
|
|
mode = body.get("mode", "private")
|
|
published = body.get("published", False)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
|
(mode, 1 if published else 0, page_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok", "share_mode": mode, "published": published}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/pages/{page_id:int}/publish")
|
|
def publish_page(request: Request, page_id: int):
|
|
"""Publish a page to the web (generates publish_slug)."""
|
|
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
|
raise HTTPException(401, "Authentication required")
|
|
slug, title = publish(page_id)
|
|
run_event_sync(fire_published(page_id, slug))
|
|
return {"is_published": True, "publish_slug": slug, "title": title}
|
|
|
|
|
|
|
|
|
|
@router.delete("/api/pages/{page_id:int}/publish")
|
|
def unpublish_page(request: Request, page_id: int):
|
|
"""Unpublish a page from the web."""
|
|
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
|
raise HTTPException(401, "Authentication required")
|
|
unpublish(page_id)
|
|
run_event_sync(fire_unpublished(page_id))
|
|
return {"is_published": False}
|
|
|
|
|
|
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
|
|
|
|
|
def _trash_session(request: Request):
|
|
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
|
|
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
|
|
lisible par JS et un attaquant fixe cookie ET en-tête)."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
raise HTTPException(401, "Authentication required")
|
|
return user
|
|
|
|
|
|
@router.get("/api/trash")
|
|
def list_trash(request: Request):
|
|
_trash_session(request)
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace, parent_section, deleted_at FROM pages "
|
|
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
|
|
).fetchall()
|
|
return [
|
|
{
|
|
"id": r["id"],
|
|
"name": r["title"] or "Untitled",
|
|
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
|
"path": r["workspace"] or "Private",
|
|
"deleted_at": r["deleted_at"],
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
|
|
|
|
@router.post("/api/trash/{page_id}/restore")
|
|
def restore_page(request: Request, page_id: int):
|
|
_trash_session(request)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
|
conn.commit()
|
|
try:
|
|
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
|
|
except Exception:
|
|
logger.exception("restore_page")
|
|
return {"status": "ok", "restored": page_id}
|
|
|
|
|
|
|
|
|
|
@router.delete("/api/trash/{page_id}")
|
|
def permanent_delete(request: Request, page_id: int):
|
|
_trash_session(request)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
|
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
|
conn.commit()
|
|
return {"status": "ok", "deleted": page_id}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/trash/empty")
|
|
def empty_trash(request: Request):
|
|
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
|
|
en racine, comme permanent_delete — comportement historique conservé)."""
|
|
_trash_session(request)
|
|
with get_conn() as conn:
|
|
n = conn.execute(
|
|
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
|
|
).fetchone()[0]
|
|
conn.execute(
|
|
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
|
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
|
)
|
|
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
|
conn.commit()
|
|
return {"status": "ok", "deleted": n}
|
|
|
|
|
|
|
|
|
|
@router.get("/trash", response_class=HTMLResponse)
|
|
def trash_page(request: Request):
|
|
from app.templating import ENV
|
|
env = ENV
|
|
template = env.get_template("trash.html")
|
|
return template.render(**_sidebar_data(request))
|
|
|
|
|
|
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
|
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|