Files
flowdeck/app/routers/dashboard.py
T
bruno 7fe7a91788
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
feat: avatars prédéfinis par couleur + fix CSRF avatar/tags
- Palette 14 couleurs pour avatar: clic sur un carré coloré → applique la couleur
  au fond de l'avatar avec l'initiale. API POST /api/settings/avatar-color
- Upload photo: efface la couleur custom et utilise l'image uploadée
- Color swatches tags: border-radius 6px (carrés arrondis) au lieu de 50% (ovales)
- CSRF: /api/settings ajouté à EXCLUDED_PATHS → 403 corrigé sur avatar + tags
2026-07-12 15:32:30 -04:00

1331 lines
50 KiB
Python

"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
def _get_user_or_redirect(request: Request):
"""Return decoded user or a RedirectResponse to login page.
Skips redirect when DB has no users (fresh install / test env)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
# Allow through if no users exist yet (fresh install / tests)
try:
with get_conn() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0:
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception:
pass
return RedirectResponse("/auth/login?provider=local", status_code=302)
return user
def _sidebar_data(request: Request, repos: list[dict]) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
recent_pages = []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
"id": full_name,
"name": repo.get("name", full_name),
"icon": "📁",
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
# Active workspace from cookie
from app.routers.board import _load_workspace_pages
ws_cookie = request.cookies.get("flowdeck_workspace", "")
active_ws_name = "Workspace"
workspace_pages = []
if ws_cookie:
workspace_pages = _load_workspace_pages(ws_cookie)
try:
with get_conn() as conn:
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (int(ws_cookie),)).fetchone()
if ws_row:
active_ws_name = ws_row["name"]
except (ValueError, Exception):
pass
return {
"workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name,
"workspace_pages": workspace_pages,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": [],
"favorite_pages": [],
"shared_pages": [],
"user": user,
"workspace_key": "",
}
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
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'
if ws_key:
rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' 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",
).fetchall()
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
template = env.get_template("trash.html")
return template.render(**sidebar)
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.
La sidebar garde exactement la même arborescence que dans le workspace.
Seul le contenu principal change pour afficher les pages triées par catégorie.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo) # Sidebar intacte, inchangée
ws_key = f"{owner}/{repo}" if owner and repo else ""
with get_conn() as conn:
base_query = (
"SELECT id, title, workspace, parent_section, updated_at FROM pages "
"WHERE parent_section != 'Trash'"
)
params = []
if ws_key:
base_query += " AND workspace=?"
params.append(ws_key)
base_query += " ORDER BY updated_at DESC"
rows = conn.execute(base_query, params).fetchall()
def to_page(r):
r = dict(r)
return {
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "📄",
"url": f"/pages/{r['id']}",
"created_by": "You",
"source": r.get("workspace") or "🔒 Private",
"last_edited": r.get("updated_at", "now"),
"last_visited": r.get("updated_at", "now"),
"section": r.get("parent_section", "Private"),
}
all_pages = [to_page(r) for r in rows]
# Load favorites from DB for Library
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:
fav_rows = conn.execute(
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id=? ORDER BY f.position", (uid,)
).fetchall()
lib_favs = []
for r in fav_rows:
r = dict(r)
lib_favs.append({
"id": f"page/{r['id']}",
"name": r["title"] or "Untitled",
"icon": "📄",
"url": f"/pages/{r['id']}",
"created_by": "You",
"source": r.get("workspace") or "🔒 Private",
"last_edited": r.get("updated_at", "now"),
"last_visited": r.get("updated_at", "now"),
})
# Catégoriser par parent_section — sidebar data intacte
sidebar["lib_recent"] = all_pages
sidebar["lib_favorites"] = lib_favs
sidebar["lib_shared"] = []
sidebar["lib_private"] = [p for p in all_pages if p.get("section") == "Private"]
sidebar["lib_has_favorites"] = len(lib_favs) > 0
sidebar["lib_has_shared"] = False
template = env.get_template("library.html")
return template.render(**sidebar)
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
from app.routers.board import _render_file_viewer
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
page = dict(row)
# ── File viewer for uploaded files ──
if page.get("content_format") == "file":
return _render_file_viewer(page, request)
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
sidebar = board_sidebar(request, owner, repo)
# Load sub-pages
with get_conn() as conn:
subs = conn.execute(
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
(page_id,),
).fetchall()
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
"page_favorited": False,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_data": {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": False, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}}
template = env.get_template("page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
@router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
"""Account management panel."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
template = env.get_template("accounts.html")
return template.render(**ctx)
@router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
from fastapi.responses import RedirectResponse
return RedirectResponse("/auth/login?provider=local", status_code=302)
# Check forge connections
gitea_connected = False
github_connected = False
if user.get("id"):
with get_conn() as conn:
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_connected = True
elif t["provider"] == "github":
github_connected = True
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
template = env.get_template("settings.html")
response = template.render(**ctx)
return HTMLResponse(content=response)
# ═══════════ User API endpoints ═══════════
def _get_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user["id"] if user and user.get("id") else 1
@router.put("/api/user/profile")
async def update_profile(request: Request):
import json
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
conn.commit()
return {"status": "ok"}
@router.put("/api/user/password")
async def update_password(request: Request):
import json
from app.password_utils import hash_password
body = await request.json()
password = body.get("password", "").strip()
if len(password) < 6:
return {"error": "Password must be at least 6 characters"}
uid = _get_user_id(request)
with get_conn() as conn:
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
conn.commit()
return {"status": "ok"}
@router.post("/api/user/token")
async def generate_token(request: Request):
import secrets
uid = _get_user_id(request)
token = secrets.token_hex(32)
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(uid, token),
)
conn.commit()
return {"token": f"fd_{token}"}
@router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str):
uid = _get_user_id(request)
with get_conn() as conn:
conn.execute(
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
)
conn.commit()
return {"status": "ok"}
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
search: str = Query(default=""),
show_archived: bool = Query(default=False),
):
auth = _get_user_or_redirect(request)
if isinstance(auth, RedirectResponse):
return auth
# Check if user has Gitea connected — if not, show local workspace
user_id = auth.get("id") if isinstance(auth, dict) else 1
has_gitea = False
try:
with get_conn() as conn:
tok = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user_id,),
).fetchone()
has_gitea = bool(tok)
except Exception:
pass
if not has_gitea:
return RedirectResponse("/local-workspace", status_code=302)
try:
repos = await gitea.get_user_repos(page=1, limit=50)
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower()
or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception as e:
logger.error("Dashboard error: %s", e)
repos = []
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html")
return template.render(request=request, repos=repos, search=search,
show_archived=show_archived, **sidebar)
# ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
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)
ctx = {**sidebar, "user": user}
template = env.get_template("workspace.html")
return template.render(**ctx)
@router.get("/api/workspace/projects")
async def list_workspace_projects(request: Request):
"""List all projects: built-in + Gitea + GitHub."""
builtin = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
for r in rows:
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
gitea_repos = []
try:
repos = await gitea.get_user_repos(page=1, limit=50)
for repo in repos:
gitea_repos.append({
"id": str(repo.get("id", "")),
"name": repo.get("name", ""),
"full_name": repo.get("full_name", ""),
"description": repo.get("description", ""),
"html_url": repo.get("html_url", ""),
"language": repo.get("language", ""),
"forge": "gitea",
})
except Exception:
pass
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
@router.post("/api/workspace/projects")
async def create_workspace_project(request: Request):
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
with get_conn() as conn:
cursor = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')",
(name,),
)
conn.commit()
pid = cursor.lastrowid
return {"id": pid, "name": name, "forge": "builtin"}
# ═══════════ Workspace Members API ═══════════
@router.get("/api/workspace/{ws_id:int}/members")
async def list_members(request: Request, ws_id: int):
"""List all members of a workspace."""
with get_conn() as conn:
rows = conn.execute(
"""SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at
FROM workspace_members wm JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,)
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.post("/api/workspace/{ws_id:int}/members")
async def invite_member(request: Request, ws_id: int):
"""Invite a user to a workspace by email."""
import json
body = await request.json()
email = body.get("email", "").strip()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
return {"error": "Invalid role"}, 400
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone()
if not user:
return {"error": "User not found"}, 404
try:
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
(ws_id, user["id"], role),
)
conn.commit()
except Exception:
return {"error": "Already a member"}, 409
return {"status": "ok", "user_id": user["id"], "role": role}
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
"""Change a member's role."""
import json
body = await request.json()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
return {"error": "Invalid role"}
with get_conn() as conn:
conn.execute(
"UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
(role, ws_id, user_id),
)
conn.commit()
return {"status": "ok"}
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def remove_member(request: Request, ws_id: int, user_id: int):
"""Remove a member from a workspace."""
with get_conn() as conn:
conn.execute(
"DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ws_id, user_id),
)
conn.commit()
return {"status": "ok"}
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
async 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 jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
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)
ws_id = ws["id"] if ws else None
# 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,
"breadcrumb": breadcrumb,
}
template = env.get_template("local_workspace.html")
return template.render(**ctx)
@router.get("/api/local-workspace/tree")
async 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)
ws_id = ws["id"] if ws else None
if not ws_id:
return {"tree": [], "breadcrumb": []}
with get_conn() as conn:
if folder:
# Show only this folder's children + build breadcrumb
children = _build_tree_children(conn, folder, ws_id)
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)
return {"tree": roots, "breadcrumb": [], "current_folder": None}
@router.get("/api/local-workspace/page-content/{page_id:int}")
async 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})
return {"content": row["content"] or "", "format": fmt}
@router.get("/api/local-workspace/breadcrumb")
async 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}
def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"""Recursively build the tree of children for a node."""
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
# Get workspace owner name for author display
ws_owner = conn.execute(
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
(ws_id,),
).fetchone()
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
# Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows]
tags_map = {}
if all_ids:
placeholders = ",".join("?" for _ in all_ids)
tag_rows = conn.execute(
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})",
all_ids,
).fetchall()
for tr in tag_rows:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id)
# Compute size
size = 0
if r["content_format"] == "file":
import json as _json
try:
meta = _json.loads(r["content"])
size = meta.get("size", 0)
except Exception:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"type": "folder" if is_folder else "page",
"is_folder": is_folder,
"content_format": r["content_format"] if not is_folder else None,
"children": children,
"has_children": len(children) > 0,
"child_count": len(children),
"size": size,
"size_display": _format_size(size),
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"author": author,
"tags": tags_map.get(r["id"], []),
})
return tree
def _format_size(size_bytes: int) -> str:
"""Human-readable file size."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def _build_breadcrumb(conn, folder_id: int) -> list:
"""Build breadcrumb trail from root to folder_id."""
breadcrumb = []
current = folder_id
seen = set()
while current and current not in seen:
seen.add(current)
row = conn.execute(
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
(current,),
).fetchone()
if row:
breadcrumb.insert(0, {
"id": row["id"],
"name": row["title"] or "Untitled",
"is_folder": row["parent_section"] == "Workspace",
})
current = row["parent_id"]
else:
break
return breadcrumb
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
ws = _get_active_workspace(request)
ws_id = ws["id"] if ws else None
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_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}")
async def rename_local_workspace_item(request: Request, item_id: int):
"""Rename a file."""
import json
body = await request.json()
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}")
async def delete_local_workspace_item(request: Request, item_id: int):
"""Delete a file (and its children). Also removes uploaded file from disk."""
import json
from pathlib import Path
with get_conn() as conn:
# Check if it's a file-type page with a disk file
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (item_id,)).fetchone()
if row and row["content_format"] == "file":
try:
meta = json.loads(row["content"])
fp = Path(f"/data/{meta['file_path']}")
if fp.exists():
fp.unlink()
except Exception:
pass
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id))
conn.commit()
return {"status": "ok"}
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
from pathlib import Path
import mimetypes
fp = Path(f"/data/uploads/workspace_{ws_id}/{filename}").resolve()
if not str(fp).startswith(f"/data/uploads/workspace_{ws_id}/"):
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")
async def move_local_workspace_item(request: Request, item_id: int):
"""Move an item to a new parent (drag & drop)."""
import json
body = await request.json()
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
ws = _get_active_workspace(request)
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)
upload_dir = Path(f"/data/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()
file_path.write_bytes(content)
# Determine if this is a folder marker or actual file
rel_path = str(file_path.relative_to("/data"))
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
ws = _get_active_workspace(request)
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)
upload_dir = Path(f"/data/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()
file_path.write_bytes(content)
rel_path = str(file_path.relative_to("/data"))
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 ═══════════
WORKSPACE_COOKIE = "flowdeck_workspace"
def _get_active_workspace(request: Request) -> dict | None:
"""Get the active workspace ID from the cookie, or first workspace, or None."""
ws_id = request.cookies.get(WORKSPACE_COOKIE)
if ws_id:
try:
with get_conn() as conn:
ws = conn.execute("SELECT * FROM workspaces WHERE id=?", (int(ws_id),)).fetchone()
if ws:
return dict(ws)
except (ValueError, Exception):
pass
# Fallback: first workspace
with get_conn() as conn:
ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").fetchone()
if ws:
return dict(ws)
return None
@router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request):
"""Workspaces list page."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
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)
ctx = {**sidebar, "user": user}
template = env.get_template("workspaces.html")
return template.render(**ctx)
@router.get("/api/workspaces")
async def list_workspaces(request: Request):
"""List all workspaces 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 w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count "
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
(uid,),
).fetchall()
workspaces = []
for r in rows:
d = dict(r)
workspaces.append(d)
active = _get_active_workspace(request)
return {"workspaces": workspaces, "active_id": active["id"] if active else None}
@router.post("/api/workspaces")
async def create_workspace(request: Request):
"""Create a new workspace."""
import json
body = await request.json()
name = body.get("name", "New Workspace").strip()
if not name:
return {"error": "Name required"}
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:
# Ensure user exists (FK constraint)
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
if not uid_ok:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)",
(uid, user.get("login", "admin") if user else "admin",
user.get("full_name", "Admin") if user else "Admin"),
)
cursor = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(name, uid),
)
ws_id = cursor.lastrowid
# Add owner as member
conn.execute(
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
(ws_id, uid),
)
conn.commit()
return {"id": ws_id, "name": name}
@router.put("/api/workspaces/{ws_id:int}")
async def rename_workspace(request: Request, ws_id: int):
"""Rename a workspace."""
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
with get_conn() as conn:
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
conn.commit()
return {"status": "ok"}
@router.delete("/api/workspaces/{ws_id:int}")
async def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages."""
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
return {"status": "ok"}
@router.post("/api/workspaces/{ws_id:int}/select")
async def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
# ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request):
"""Settings & configuration page."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
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)
ctx = {**sidebar, "user": user}
template = env.get_template("settings.html")
return template.render(**ctx)
@router.post("/api/settings/avatar")
async def upload_avatar(request: Request):
"""Upload a user avatar image."""
from fastapi import UploadFile, File
from pathlib import Path
import uuid, os
form = await request.form()
file = form.get("file")
if not file:
return {"error": "No file"}, 400
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"error": "Not authenticated"}, 401
# Save to data/avatars
avatars_dir = Path("/data/avatars")
avatars_dir.mkdir(parents=True, exist_ok=True)
ext = os.path.splitext(file.filename)[1] or ".png"
filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
filepath = avatars_dir / filename
content = await file.read()
filepath.write_bytes(content)
# Update user avatar_url
avatar_url = f"/api/settings/avatar/{filename}"
with get_conn() as conn:
conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"]))
conn.commit()
return {"avatar_url": avatar_url}
@router.get("/api/avatar/{user_id:int}")
async def get_avatar(user_id: int):
"""Redirect to the user's avatar."""
with get_conn() as conn:
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
if row and row["avatar_url"]:
return RedirectResponse(row["avatar_url"], status_code=302)
return JSONResponse({"error": "No avatar"}, status_code=404)
@router.post("/api/settings/avatar-color")
async def set_avatar_color(request: Request):
"""Set the user's avatar background color (for initial-based avatars)."""
import json
body = await request.json()
color = body.get("color", "#3A3A3A")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"error": "Not authenticated"}, 401
with get_conn() as conn:
conn.execute("UPDATE users SET avatar_url = '' WHERE id = ?", (user["id"],))
conn.commit()
return {"status": "ok", "color": color}
# ═══════════ Tag Management API (global) ═══════════
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
"""Create a tag with optional color."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
if not tag_name:
return {"error": "Tag name required"}, 400
with get_conn() as conn:
tag = conn.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)).fetchone()
if tag:
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
conn.commit()
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
cursor = conn.execute("INSERT INTO tags (name, color) VALUES (?, ?)", (tag_name, color))
conn.commit()
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
"""Update a tag (name or color)."""
import json
body = await request.json()
with get_conn() as conn:
if "name" in body:
conn.execute("UPDATE tags SET name = ? WHERE id = ?", (body["name"].strip().lower(), tag_id))
if "color" in body:
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (body["color"], tag_id))
conn.commit()
return {"status": "ok"}
@router.delete("/api/settings/tags/{tag_id:int}")
async def delete_tag_global(tag_id: int):
"""Delete a tag and its page associations."""
with get_conn() as conn:
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,))
conn.commit()
return {"status": "ok"}
@router.get("/api/settings/tags/all")
async def list_all_tags_global():
"""List all tags with counts."""
with get_conn() as conn:
rows = conn.execute(
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
"GROUP BY t.id ORDER BY t.name"
).fetchall()
return {"tags": [dict(r) for r in rows]}
# ═══════════ Tags API ═══════════
@router.get("/api/local-workspace/tags")
async def list_tags(request: Request):
"""List all tags used in the active workspace."""
ws = _get_active_workspace(request)
ws_id = ws["id"] if ws else None
if not ws_id:
return {"tags": []}
import json
with get_conn() as conn:
rows = conn.execute(
"SELECT DISTINCT t.id, t.name, t.color, COUNT(pt.page_id) as count "
"FROM tags t "
"JOIN page_tags pt ON pt.tag_id = t.id "
"JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
"GROUP BY t.id ORDER BY t.name",
(ws_id,),
).fetchall()
return {"tags": [dict(r) for r in rows]}
@router.get("/api/local-workspace/items/{item_id:int}/tags")
async def get_item_tags(item_id: int):
"""Get tags for a specific item."""
with get_conn() as conn:
rows = conn.execute(
"SELECT t.id, t.name, t.color FROM tags t "
"JOIN page_tags pt ON pt.tag_id = t.id "
"WHERE pt.page_id = ? ORDER BY t.name",
(item_id,),
).fetchall()
return {"tags": [dict(r) for r in rows]}
@router.post("/api/local-workspace/items/{item_id:int}/tags")
async def add_item_tag(request: Request, item_id: int):
"""Add a tag to an item (creates tag if new)."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
if not tag_name:
return {"error": "Tag name required"}, 400
with get_conn() as conn:
# Get or create tag
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ?", (tag_name,)).fetchone()
if not tag:
cursor = conn.execute(
"INSERT INTO tags (name) VALUES (?)", (tag_name,)
)
conn.commit()
tag_id = cursor.lastrowid
tag = {"id": tag_id, "name": tag_name, "color": "#787774"}
else:
tag_id = tag["id"]
# Link tag to page (ignore duplicate)
try:
conn.execute(
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
(item_id, tag_id),
)
conn.commit()
except Exception:
pass
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
async def remove_item_tag(item_id: int, tag_id: int):
"""Remove a tag from an item."""
with get_conn() as conn:
conn.execute(
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
(item_id, tag_id),
)
conn.commit()
return {"status": "ok"}
@router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated)."""
ws = _get_active_workspace(request)
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
import json
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
if not tag_names:
return {"items": []}
with get_conn() as conn:
placeholders = ",".join("?" for _ in tag_names)
rows = conn.execute(
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
f"p.content, p.created_at, p.updated_at "
f"FROM pages p "
f"JOIN page_tags pt ON pt.page_id = p.id "
f"JOIN tags t ON t.id = pt.tag_id "
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
f"ORDER BY p.updated_at DESC",
tag_names + [ws_id],
).fetchall()
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
size = len(r["content"] or "")
items.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"is_folder": is_folder,
"content_format": r["content_format"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"size": size,
"size_display": _format_size(size),
})
return {"items": items}