Découpe par concern de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) en package `app/routers/dashboard/` : - 8 modules de routes : local_workspace 559 L (15 r.), pages_html 485 (6), account_settings 439 (16), workspace 321 (9), pages_api 240 (6), workspaces 131 (6), public 78 (1), account_api 77 (4) - _common.py (774 L) : les 15 helpers top-level INTERCALÉS dans l'ancien fichier + état (logger, _VERSION, WORKSPACE_COOKIE) - __init__.py : ordre d'enregistrement identique à l'origine, re-export complet (7 importateurs inchangés : main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests) + __all__ Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - segment décorateur sans sa fonction → assert `def in seg` + récupération git (corps perdus en silence à la 1ʳᵉ exécution) - collision `settings` (section vs from app.config import settings → hasattr du fromlist) → renommée account_settings - WORKSPACE_COOKIE utilisé sans import dans workspaces.py (F821) - script __all__ mangeant la fin du fichier → __init__ réécrit Reste A28 : collections.py 2 622 L, board.py 2 101 L (lot 3). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
560 lines
20 KiB
Python
560 lines
20 KiB
Python
"""FlowDeck — Dashboard : local_workspace.
|
|
|
|
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC
|
|
|
|
from fastapi import APIRouter, Body, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.config import settings
|
|
from app.db import get_conn
|
|
|
|
from ._common import (
|
|
_build_breadcrumb,
|
|
_build_tree_children,
|
|
_file_page_disk_path,
|
|
_get_active_workspace,
|
|
_get_user_id,
|
|
_require_page_view,
|
|
_require_user_id,
|
|
_sidebar_data,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["dashboard"])
|
|
|
|
|
|
|
|
|
|
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
|
|
|
@router.get("/local-workspace", response_class=HTMLResponse)
|
|
def local_workspace_page(request: Request, folder: int = None):
|
|
"""Local workspace page with file/folder tree.
|
|
|
|
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
|
"""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
|
|
|
ws = _get_active_workspace(request, user_id=user["id"])
|
|
ws_id = ws["id"] if ws else None
|
|
|
|
# If no workspace exists for this user, redirect to workspaces page
|
|
if not ws_id:
|
|
return RedirectResponse("/workspaces", status_code=302)
|
|
|
|
# Build breadcrumb if navigating into a folder
|
|
breadcrumb = []
|
|
current_folder_id = folder
|
|
if folder and ws_id:
|
|
with get_conn() as conn:
|
|
breadcrumb = _build_breadcrumb(conn, folder)
|
|
|
|
ctx = {
|
|
**sidebar,
|
|
"user": user,
|
|
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
|
"current_folder_id": current_folder_id or 0,
|
|
"workspace_id": ws_id or 0,
|
|
"nav_workspace_id": ws_id or 0,
|
|
"breadcrumb": breadcrumb,
|
|
"breadcrumbs": breadcrumb,
|
|
}
|
|
template = env.get_template("local_workspace.html")
|
|
return HTMLResponse(
|
|
content=template.render(**ctx),
|
|
headers={
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
"Pragma": "no-cache",
|
|
"Expires": "0",
|
|
}
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.get("/api/local-workspace/tree")
|
|
def local_workspace_tree(request: Request, folder: int = None):
|
|
"""Return the file/folder tree filtered by active workspace.
|
|
|
|
If ?folder=ID is provided, returns only that folder's children.
|
|
Otherwise returns the full recursive tree from root.
|
|
"""
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return {"tree": [], "breadcrumb": []}
|
|
uid = _get_user_id(request)
|
|
|
|
with get_conn() as conn:
|
|
if folder:
|
|
# Show only this folder's children + build breadcrumb
|
|
children = _build_tree_children(conn, folder, ws_id, uid)
|
|
breadcrumb = _build_breadcrumb(conn, folder)
|
|
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
|
|
else:
|
|
# Full tree from root
|
|
roots = _build_tree_children(conn, None, ws_id, uid)
|
|
return {"tree": roots, "breadcrumb": [], "current_folder": None}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
|
def get_page_content(page_id: int):
|
|
"""Return the raw content of a page (for preview)."""
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
if not row:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
fmt = row["content_format"]
|
|
if fmt == "file":
|
|
return JSONResponse({"content": "(uploaded file)", "format": fmt})
|
|
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
|
from app.services.synced_blocks import resolve_content_json
|
|
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/pages/{page_id}/download")
|
|
def download_page_file(request: Request, page_id: int):
|
|
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
|
_require_page_view(request, page_id)
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id, title, content, content_format FROM pages "
|
|
"WHERE id=? AND deleted_at IS NULL",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
resolved = _file_page_disk_path(dict(row))
|
|
if not resolved:
|
|
return JSONResponse({"error": "No downloadable file"}, status_code=404)
|
|
full, filename, mime, _size = resolved
|
|
from fastapi.responses import FileResponse
|
|
return FileResponse(
|
|
str(full), media_type=mime or "application/octet-stream",
|
|
filename=filename, content_disposition_type="attachment",
|
|
)
|
|
|
|
|
|
|
|
|
|
@router.get("/api/pages/{page_id}/file-content")
|
|
def page_file_content(request: Request, page_id: int):
|
|
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
|
|
|
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
|
only meaningful for plain-text / code / markdown files.
|
|
"""
|
|
from app.services.export import _file_text
|
|
|
|
_require_page_view(request, page_id)
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id, title, content, content_format FROM pages "
|
|
"WHERE id=? AND deleted_at IS NULL",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
page = dict(row)
|
|
text = _file_text(page)
|
|
if text is None:
|
|
return JSONResponse(
|
|
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
|
|
status_code=415,
|
|
)
|
|
return {"ok": True, "name": page.get("title") or "File", "content": text}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/local-workspace/breadcrumb")
|
|
def local_workspace_breadcrumb(request: Request, folder: int):
|
|
"""Return breadcrumb trail for a folder."""
|
|
with get_conn() as conn:
|
|
breadcrumb = _build_breadcrumb(conn, folder)
|
|
return {"breadcrumb": breadcrumb}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/nav/menu")
|
|
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
|
"""Return the pages at one level for the header breadcrumb navigation menu.
|
|
|
|
If ``parent_id`` is given, returns that page's children; otherwise the
|
|
workspace's root pages. Each item includes ``has_children`` so the frontend
|
|
can render an expandable sub-menu.
|
|
"""
|
|
from app.routers.board import _file_icon
|
|
ws_id = workspace_id
|
|
if not ws_id:
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return {"items": []}
|
|
with get_conn() as conn:
|
|
if parent_id:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format FROM pages "
|
|
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
|
"ORDER BY sort_order ASC, created_at DESC",
|
|
(parent_id, ws_id),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format FROM pages "
|
|
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
|
"ORDER BY sort_order ASC, created_at DESC",
|
|
(ws_id,),
|
|
).fetchall()
|
|
ids = [r["id"] for r in rows]
|
|
child_counts = {}
|
|
if ids:
|
|
placeholders = ",".join("?" for _ in ids)
|
|
cc_rows = conn.execute(
|
|
f"SELECT parent_id, COUNT(*) AS c FROM pages "
|
|
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
|
f"GROUP BY parent_id",
|
|
ids,
|
|
).fetchall()
|
|
for cr in cc_rows:
|
|
child_counts[cr["parent_id"]] = cr["c"]
|
|
items = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
title = r["title"] or "Untitled"
|
|
items.append({
|
|
"id": r["id"],
|
|
"name": title,
|
|
"icon": "folder" if is_folder else _file_icon(title, r["content_format"]),
|
|
"has_children": child_counts.get(r["id"], 0) > 0,
|
|
"url": f"/pages/{r['id']}",
|
|
})
|
|
return {"items": items}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/local-workspace/items")
|
|
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
|
"""Create a new file in the active workspace."""
|
|
name = (body.get("name") or "").strip() or "Untitled"
|
|
item_type = body.get("type", "page")
|
|
parent_id = body.get("parent_id")
|
|
explicit_ws_id = body.get("workspace_id")
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = explicit_ws_id or (ws["id"] if ws else None)
|
|
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
|
|
section = 'Workspace' if item_type == 'folder' else 'Private'
|
|
with get_conn() as conn:
|
|
cursor = conn.execute(
|
|
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
|
|
"VALUES (?, ?, ?, '', 'blocks', ?, ?)",
|
|
(ws_key, ws_id, name, section, parent_id)
|
|
)
|
|
conn.commit()
|
|
pid = cursor.lastrowid
|
|
return {"id": pid, "name": name, "type": item_type}
|
|
|
|
|
|
|
|
|
|
@router.put("/api/local-workspace/items/{item_id:int}")
|
|
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
|
"""Rename a file."""
|
|
name = body.get("name", "Untitled").strip()
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.delete("/api/local-workspace/items/{item_id:int}")
|
|
def delete_local_workspace_item(request: Request, item_id: int):
|
|
"""Soft-delete a file/folder (sets deleted_at)."""
|
|
from datetime import datetime
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
|
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
|
def restore_local_workspace_item(request: Request, item_id: int):
|
|
"""Restore a soft-deleted file/folder."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
|
(item_id,),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
|
def serve_uploaded_file(ws_id: int, filename: str):
|
|
"""Serve an uploaded file from disk."""
|
|
import mimetypes
|
|
from pathlib import Path
|
|
root = Path(settings.data_dir)
|
|
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
|
fp = (base_dir / filename).resolve()
|
|
try:
|
|
fp.relative_to(base_dir)
|
|
except ValueError:
|
|
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
|
if not fp.exists():
|
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
|
mime, _ = mimetypes.guess_type(str(fp))
|
|
content = fp.read_bytes()
|
|
from fastapi.responses import Response
|
|
return Response(content=content, media_type=mime or "application/octet-stream")
|
|
|
|
|
|
|
|
|
|
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
|
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
|
"""Move an item to a new parent (drag & drop)."""
|
|
new_parent_id = body.get("parent_id") # None = move to root
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET parent_id=? WHERE id=?",
|
|
(new_parent_id, item_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/local-workspace/upload")
|
|
async def upload_local_workspace_file(request: Request):
|
|
"""Upload one or more files via drag-and-drop.
|
|
|
|
Accepts multipart form with 'files' field (one or multiple files).
|
|
Optional: 'parent_id' to place files in a specific folder.
|
|
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
|
ws = _get_active_workspace(request, user_id=uid)
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
|
|
|
try:
|
|
form = await request.form()
|
|
except Exception:
|
|
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
|
|
|
parent_id_raw = form.get("parent_id")
|
|
parent_id = int(parent_id_raw) if parent_id_raw else None
|
|
files = form.getlist("files")
|
|
|
|
if not files:
|
|
return JSONResponse({"error": "No files provided"}, status_code=400)
|
|
|
|
|
|
from app.middleware.security import validate_upload
|
|
|
|
data_root = Path(settings.data_dir)
|
|
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
results = []
|
|
with get_conn() as conn:
|
|
for f in files:
|
|
filename = f.filename or "untitled"
|
|
# Sanitize filename: only keep basename, prevent path traversal
|
|
safe_name = Path(filename).name
|
|
if not safe_name:
|
|
safe_name = "untitled"
|
|
|
|
# Unique filename on disk
|
|
file_path = upload_dir / safe_name
|
|
stem, suffix = file_path.stem, file_path.suffix
|
|
counter = 1
|
|
while file_path.exists():
|
|
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
|
counter += 1
|
|
|
|
content = await f.read()
|
|
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
|
if err:
|
|
results.append({"name": safe_name, "error": err})
|
|
continue
|
|
file_path.write_bytes(content)
|
|
|
|
# Determine if this is a folder marker or actual file
|
|
rel_path = str(file_path.relative_to(data_root))
|
|
size = len(content)
|
|
mime = f.content_type or "application/octet-stream"
|
|
|
|
cursor = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
|
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
|
(ws_id, file_path.name,
|
|
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
|
|
parent_id),
|
|
)
|
|
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
|
|
|
|
conn.commit()
|
|
|
|
return {"status": "ok", "items": results}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/local-workspace/upload-folder")
|
|
async def upload_local_workspace_folder(request: Request):
|
|
"""Handle recursive folder upload.
|
|
|
|
Frontend walks the directory tree with webkitGetAsEntry and sends:
|
|
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
|
|
- 'files': multipart files (one per file in the structure)
|
|
- 'parent_id': target folder (optional)
|
|
|
|
Creates folders first, then uploads files into their respective folders.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
|
ws = _get_active_workspace(request, user_id=uid)
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
|
|
|
try:
|
|
form = await request.form()
|
|
except Exception:
|
|
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
|
|
|
parent_id_raw = form.get("parent_id")
|
|
root_parent_id = int(parent_id_raw) if parent_id_raw else None
|
|
structure_raw = form.get("structure")
|
|
|
|
if not structure_raw:
|
|
return JSONResponse({"error": "No structure provided"}, status_code=400)
|
|
|
|
try:
|
|
structure = json.loads(structure_raw)
|
|
except json.JSONDecodeError:
|
|
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
|
|
|
|
|
from app.middleware.security import validate_upload
|
|
|
|
data_root = Path(settings.data_dir)
|
|
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
results = []
|
|
created_folders = {} # relative_path -> db_id
|
|
|
|
with get_conn() as conn:
|
|
# Phase 1: Create all folders
|
|
for item in structure:
|
|
if item.get("type") != "folder":
|
|
continue
|
|
path_parts = item["path"].strip("/").split("/")
|
|
folder_name = path_parts[-1]
|
|
# Determine parent: parent of this folder in the tree
|
|
if len(path_parts) == 1:
|
|
actual_parent = root_parent_id
|
|
else:
|
|
parent_path = "/".join(path_parts[:-1])
|
|
actual_parent = created_folders.get(parent_path)
|
|
|
|
cursor = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
|
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
|
|
(ws_id, folder_name, actual_parent),
|
|
)
|
|
fid = cursor.lastrowid
|
|
created_folders[item["path"].strip("/")] = fid
|
|
results.append({"id": fid, "name": folder_name, "type": "folder"})
|
|
|
|
# Phase 2: Upload files into their respective folders
|
|
for item in structure:
|
|
if item.get("type") != "file":
|
|
continue
|
|
path_parts = item["path"].strip("/").split("/")
|
|
file_name = path_parts[-1]
|
|
if len(path_parts) == 1:
|
|
file_parent = root_parent_id
|
|
else:
|
|
parent_path = "/".join(path_parts[:-1])
|
|
file_parent = created_folders.get(parent_path)
|
|
|
|
# Find the matching file in multipart data
|
|
matched = None
|
|
for f in form.getlist("files"):
|
|
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
|
|
matched = f
|
|
break
|
|
if not matched:
|
|
continue
|
|
|
|
safe_name = Path(file_name).name
|
|
file_path = upload_dir / safe_name
|
|
stem, suffix = file_path.stem, file_path.suffix
|
|
counter = 1
|
|
while file_path.exists():
|
|
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
|
counter += 1
|
|
|
|
content = await matched.read()
|
|
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
|
if err:
|
|
results.append({"name": safe_name, "error": err})
|
|
continue
|
|
file_path.write_bytes(content)
|
|
|
|
rel_path = str(file_path.relative_to(data_root))
|
|
cursor = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
|
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
|
(ws_id, file_path.name,
|
|
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
|
|
file_parent),
|
|
)
|
|
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
|
|
|
|
conn.commit()
|
|
|
|
return {"status": "ok", "items": results}
|
|
|
|
|
|
# ═══════════ Workspaces CRUD ═══════════
|