"""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 ( WORKSPACE_COOKIE, _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, ws: int = None): """Local workspace page with file/folder tree. If ?folder=ID is provided, shows that folder's contents with breadcrumb. If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton « Home » de la sidebar (``/local-workspace?ws=``). Le paramètre était produit par le sidebar mais **ignoré** par la route : le contenu affiché restait celui du workspace *actif* (cookie), donc Home pouvait montrer un autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient pas à l'utilisateur est ignoré (et le workspace actif conservé). """ 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_row = _get_active_workspace(request, user_id=user["id"]) if ws is not None: # Workspace demandé par l'URL : on ne l'accepte que s'il appartient à # l'utilisateur (même règle de propriété que _get_active_workspace). with get_conn() as conn: requested = conn.execute( "SELECT id, name FROM workspaces WHERE id=? AND owner_id=?", (ws, user["id"]), ).fetchone() if requested is not None: ws_row = dict(requested) # L'URL fait foi : on aligne le cookie pour que le reste de # l'application (sidebar, API tree, breadcrumbs) suive le même # workspace que celui affiché. response = _render_local_workspace(env, sidebar, user, ws_row, folder) response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]), max_age=86400 * 30, httponly=True, path="/") return response ws_id = ws_row["id"] if ws_row else None # If no workspace exists for this user, redirect to workspaces page if not ws_id: return RedirectResponse("/workspaces", status_code=302) return _render_local_workspace(env, sidebar, user, ws_row, folder) def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict, folder: int | None) -> HTMLResponse: """Render the local-workspace page for ``ws_row`` (shared by both paths). ``sidebar`` a été calculé **avant** le éventuel changement de workspace (``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``, sinon la page afficherait le contenu d'un workspace sous le titre d'un autre. """ ws_id = ws_row["id"] ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace") # 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": ws_name, "active_ws_name": ws_name, "active_workspace_id": ws_id, "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.""" _require_user_id(request) # A3/A4 : pas de creation de page anonyme 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.""" _require_user_id(request) 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).""" _require_user_id(request) 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.""" _require_user_id(request) 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).""" _require_user_id(request) 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 ═══════════