feat: poubelle flexible (sélection groupée, purge, tri/filtres, dates) + 4 bugs (v7.45.5)
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.
This commit is contained in:
@@ -125,21 +125,41 @@ def unpublish_page(request: Request, page_id: int):
|
||||
# ═══════════ 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
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@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_id, 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": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
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()
|
||||
@@ -154,6 +174,7 @@ def restore_page(request: Request, page_id: int):
|
||||
|
||||
@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,))
|
||||
@@ -163,6 +184,26 @@ def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -62,11 +62,20 @@ def api_rename_page(page_id: int, request: Request, body: dict = Body(default={}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
def api_trash_page(page_id: int, request: Request):
|
||||
"""Soft-delete a page (move to trash).
|
||||
|
||||
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
|
||||
est la seule source de vérité. L'ancienne écriture marquait définitivement
|
||||
la page : à la restauration elle restait 'Trash' et disparaissait des
|
||||
listings Library / Recents / Private (``parent_section != 'Trash'``).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
# Pages are soft-deleted via parent_section='Trash'
|
||||
# Pages are soft-deleted via deleted_at (parent_section n'est plus
|
||||
# touché par le trash → source de vérité unique, v7.45.5)
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
|
||||
Reference in New Issue
Block a user