- A11 : `GET /api/settings/avatar/{filename:path}` → `resolve()` + `relative_to()` (motif de `serve_uploaded_file`), 403 hors de `/data/avatars`
- A18 : `GET /workspace/public/{id}` → 404 HTML explicite pour `permission_type` restricted/private, `html.escape` sur le nom, l'icône et les titres de lignes (le f-string HTML ne passe pas par Jinja2)
- `tests/test_audit_p0_fixes.py` : 3 tests de non-régression (traversal, échappement, hidden restricted)
- ROADMAP A11/A18 cochés · CHANGELOG/WORKLOAD/VERSION → 7.3.2 · suite **1019/1019** · `ruff check app tests` OK
2700 lines
111 KiB
Python
2700 lines
111 KiB
Python
"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services.gitea_client import get_user_gitea_client, gitea
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["dashboard"])
|
|
|
|
_VERSION = None
|
|
|
|
|
|
def _get_app_version() -> str:
|
|
"""Read version from VERSION file with caching."""
|
|
global _VERSION
|
|
if _VERSION is not None:
|
|
return _VERSION
|
|
try:
|
|
import os
|
|
version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION")
|
|
if os.path.exists(version_path):
|
|
with open(version_path) as f:
|
|
_VERSION = f.read().strip()
|
|
else:
|
|
# Docker fallback
|
|
version_path = "/app/VERSION"
|
|
if os.path.exists(version_path):
|
|
with open(version_path) as f:
|
|
_VERSION = f.read().strip()
|
|
else:
|
|
_VERSION = "0.0.0"
|
|
except Exception:
|
|
_VERSION = "0.0.0"
|
|
return _VERSION
|
|
|
|
|
|
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&expired=1", status_code=302)
|
|
return user
|
|
|
|
|
|
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
|
|
"""Return list of local workspaces for a user."""
|
|
if not user:
|
|
return []
|
|
try:
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
|
|
(user["id"],)
|
|
).fetchall()
|
|
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> 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"
|
|
|
|
# Get avatar info from DB
|
|
avatar_url = ""
|
|
avatar_color = "#3A3A3A"
|
|
if user:
|
|
try:
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone()
|
|
if row:
|
|
avatar_url = row["avatar_url"] or ""
|
|
avatar_color = row["avatar_color"] or "#3A3A3A"
|
|
except Exception:
|
|
pass
|
|
|
|
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": "folder",
|
|
"url": f"/board/{full_name}",
|
|
"active": False,
|
|
"indent": 0,
|
|
"depth": 0,
|
|
"has_children": False,
|
|
"children": [],
|
|
})
|
|
|
|
# Active workspace from cookie (skip on pages like /workspaces where no
|
|
# workspace context should be shown)
|
|
from app.routers.board import _load_workspace_pages
|
|
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
|
active_ws_name = "Workspace"
|
|
workspace_pages = []
|
|
gitea_workspace = False
|
|
gitea_owner = ""
|
|
gitea_repo = ""
|
|
has_active_workspace = False
|
|
local_ws_id = 0
|
|
|
|
if ws_cookie and ws_cookie.startswith("gitea:"):
|
|
# Gitea workspace: set owner/repo for client-side tree loading
|
|
# AND open the local workspace mirror of the same name in the
|
|
# sidebar's top "My Workspaces" section, in parallel with the
|
|
# Gitea repository tree.
|
|
parts = ws_cookie.split(":", 2)
|
|
if len(parts) >= 3:
|
|
gitea_owner = parts[1]
|
|
gitea_repo = parts[2]
|
|
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
|
gitea_workspace = True
|
|
has_active_workspace = True
|
|
# Load the local mirror workspace tree so it appears in "My
|
|
# Workspaces" alongside the Gitea repository section.
|
|
if user:
|
|
try:
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
|
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
|
).fetchone()
|
|
if row:
|
|
local_ws_id = row["id"]
|
|
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
|
except (ValueError, Exception):
|
|
pass
|
|
elif include_workspace and ws_cookie and user:
|
|
try:
|
|
wsi = int(ws_cookie)
|
|
with get_conn() as conn:
|
|
# Verify this workspace belongs to the current user
|
|
row = conn.execute(
|
|
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
|
(wsi, user["id"])
|
|
).fetchone()
|
|
if row:
|
|
active_ws_name = row["name"]
|
|
workspace_pages = _load_workspace_pages(ws_cookie)
|
|
has_active_workspace = True
|
|
# else: stale cookie from another user — ignore
|
|
except (ValueError, Exception):
|
|
pass
|
|
|
|
# Auth method & OAuth badge data
|
|
auth_method = "local"
|
|
gitea_linked = False
|
|
github_linked = False
|
|
if user and user.get("id"):
|
|
try:
|
|
with get_conn() as conn:
|
|
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
|
if am_row and am_row["auth_method"]:
|
|
auth_method = am_row["auth_method"]
|
|
tokens = conn.execute(
|
|
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
|
).fetchall()
|
|
for t in tokens:
|
|
if t["provider"] == "gitea":
|
|
gitea_linked = True
|
|
elif t["provider"] == "github":
|
|
github_linked = True
|
|
except Exception:
|
|
pass
|
|
|
|
# Get local workspace ID for Gitea workspace mirror
|
|
local_ws_id = 0
|
|
if gitea_workspace and gitea_owner and gitea_repo:
|
|
try:
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
|
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
|
).fetchone()
|
|
if row:
|
|
local_ws_id = row["id"]
|
|
except Exception:
|
|
pass
|
|
|
|
# Private pages for mirror workspace (when Gitea remote active)
|
|
private_pages = []
|
|
if gitea_workspace and local_ws_id:
|
|
try:
|
|
with get_conn() as conn:
|
|
pp_rows = conn.execute(
|
|
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
|
(local_ws_id,)
|
|
).fetchall()
|
|
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
|
except Exception:
|
|
pass
|
|
|
|
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
|
shared_made_pages = []
|
|
shared_received_pages = []
|
|
published_pages = []
|
|
shared_pages = []
|
|
if user and user.get("id"):
|
|
from app.routers.board import _load_shared_sidebar_pages
|
|
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
|
|
|
|
sidebar = {
|
|
"workspace_name": ws, "workspace_initial": initial,
|
|
"active_ws_name": active_ws_name,
|
|
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
|
|
"gitea_workspace": gitea_workspace,
|
|
"gitea_owner": gitea_owner,
|
|
"gitea_repo": gitea_repo,
|
|
"local_ws_id": local_ws_id,
|
|
"workspace_pages": workspace_pages,
|
|
"current_page": "Dashboard", "last_edited": "now",
|
|
"recent_pages": recent_pages,
|
|
"private_pages": private_pages,
|
|
"favorite_pages": [],
|
|
"shared_pages": shared_pages,
|
|
"shared_made_pages": shared_made_pages,
|
|
"shared_received_pages": shared_received_pages,
|
|
"published_pages": published_pages,
|
|
"user": user,
|
|
"avatar_url": avatar_url,
|
|
"avatar_color": avatar_color,
|
|
"auth_method": auth_method,
|
|
"gitea_linked": gitea_linked,
|
|
"github_linked": github_linked,
|
|
"has_active_workspace": has_active_workspace,
|
|
"app_version": _get_app_version(),
|
|
}
|
|
|
|
sidebar["local_workspaces"] = _local_workspaces_for_user(user)
|
|
|
|
return sidebar
|
|
|
|
|
|
@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 app.routers.board import _sidebar_data as board_sidebar
|
|
from app.templating import ENV
|
|
env = ENV
|
|
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]
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
|
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 — tabbed view (recents, favorites, shared, published, private, workspace).
|
|
|
|
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
|
"""
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = board_sidebar(request, owner, repo)
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
|
if ws_key_ws:
|
|
with get_conn() as conn:
|
|
ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone()
|
|
sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0
|
|
else:
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
|
|
|
template = env.get_template("library.html")
|
|
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
|
|
# Gitea workspace context for Repository tab
|
|
sidebar["is_gitea_workspace"] = bool(owner and repo)
|
|
sidebar["gitea_workspace_owner"] = owner
|
|
sidebar["gitea_workspace_repo"] = repo
|
|
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.
|
|
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
|
embed = request.query_params.get("embed") == "1"
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
from app.templating import ENV
|
|
env = ENV
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
return RedirectResponse("/workspaces", status_code=302)
|
|
page = dict(row)
|
|
# v6.5.0: synced blocks resolve server-side at read time.
|
|
from app.services.synced_blocks import resolve_content_json
|
|
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
|
|
|
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()
|
|
fav = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?",
|
|
(1, page_id),
|
|
).fetchone()
|
|
|
|
# Build page_data, including file metadata for uploaded files
|
|
_locked = bool(page.get("is_locked", 0))
|
|
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
|
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
|
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
|
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
|
|
"is_locked": _locked,
|
|
"locked_by": _locked_by,
|
|
"can_edit": _can_edit,
|
|
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
|
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
|
|
|
# For file pages, extract file metadata and add to page_data
|
|
if page.get("content_format") == "file":
|
|
import json as _json
|
|
try:
|
|
meta = _json.loads(page.get("content", "{}"))
|
|
except _json.JSONDecodeError:
|
|
meta = {}
|
|
file_path = meta.get("file_path", "").replace("\\", "/")
|
|
mime_type = meta.get("mime_type", "application/octet-stream")
|
|
file_size = meta.get("size", 0)
|
|
fp_parts = file_path.split("/")
|
|
ws_id = ""
|
|
for p in fp_parts:
|
|
if p.startswith("workspace_"):
|
|
ws_id = p.replace("workspace_", "")
|
|
break
|
|
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
|
from urllib.parse import quote
|
|
safe_name = quote(filename, safe='')
|
|
file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else ""
|
|
page_data["file_url"] = file_url
|
|
page_data["file_mime"] = mime_type
|
|
page_data["file_size"] = file_size
|
|
page_data["file_name"] = filename
|
|
|
|
# For collection (database) pages, load collection + properties + pages
|
|
collection_data = None
|
|
if page.get("content_format") == "collection" and page.get("collection_id"):
|
|
with get_conn() as conn:
|
|
col = conn.execute(
|
|
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
|
|
).fetchone()
|
|
if col:
|
|
props = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(page["collection_id"],),
|
|
).fetchall()
|
|
]
|
|
cpages = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
|
(page["collection_id"],),
|
|
).fetchall()
|
|
]
|
|
cviews = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
|
(page["collection_id"],),
|
|
).fetchall()
|
|
]
|
|
collection_data = {
|
|
"collection": dict(col),
|
|
"properties": props,
|
|
"pages": cpages,
|
|
"views": cviews,
|
|
}
|
|
page_data["collection_id"] = page["collection_id"]
|
|
|
|
with get_conn() as conn:
|
|
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
|
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
|
"page_favorited": fav is not None,
|
|
"page_share_mode": page.get("share_mode", "private"),
|
|
"page_published": bool(page.get("published", 0)),
|
|
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
|
"page_data": page_data,
|
|
"collection_data": collection_data,
|
|
"breadcrumb_items": nav_crumbs,
|
|
"nav_workspace_id": page.get("workspace_id") or 0,
|
|
"nav_page_id": page_id,
|
|
"embed_mode": embed}
|
|
# Select template: collection pages use database table view
|
|
if page.get("content_format") == "collection" and not embed:
|
|
template = env.get_template("page_editor_collection.html")
|
|
else:
|
|
template = env.get_template("page_editor_embed.html" if embed else "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 app.templating import ENV
|
|
env = ENV
|
|
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("/help", response_class=HTMLResponse)
|
|
async def help_page(request: Request):
|
|
"""Comprehensive help & documentation page."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, [])
|
|
# Render via a block-based template so content_html lands in {% block content %}
|
|
block_tpl = env.from_string(
|
|
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
|
)
|
|
return HTMLResponse(block_tpl.render(
|
|
**sidebar,
|
|
request=request,
|
|
page_title="Help",
|
|
title_prefix="Help",
|
|
page_icon="❓",
|
|
content_html="""<style>
|
|
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
|
|
.help-hero{text-align:center;margin-bottom:48px;}
|
|
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
|
|
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
|
|
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
|
|
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
|
|
.help-card:hover{border-color:rgba(255,255,255,.12);}
|
|
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
|
|
.help-card .icon{font-size:20px;}
|
|
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
|
|
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
|
|
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
|
|
.help-card li::before{content:'• ';color:var(--accent);}
|
|
.help-section{margin-bottom:48px;}
|
|
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
|
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
|
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
|
.help-shortcut-row:hover{background:var(--bg-hover);}
|
|
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
|
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
|
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
|
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
|
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
|
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
|
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
|
</style>
|
|
<div class="help-page">
|
|
<div class="help-hero">
|
|
<h1>❓ FlowDeck Help</h1>
|
|
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
|
|
</div>
|
|
|
|
<div class="help-grid">
|
|
<div class="help-card">
|
|
<h3><span class="icon">🚀</span>Getting Started</h3>
|
|
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
|
|
<ul>
|
|
<li>Create a workspace from the <b>Workspaces</b> page</li>
|
|
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
|
|
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">📝</span>Pages & Editor</h3>
|
|
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
|
|
<ul>
|
|
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
|
|
<li>Drag & drop pages in the sidebar to reorganize</li>
|
|
<li>Right-click for context menu (duplicate, rename, delete)</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
|
|
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
|
|
<ul>
|
|
<li><span class="help-badge local">Local</span> Files stored on your server</li>
|
|
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
|
|
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">🦎</span>Gitea Integration</h3>
|
|
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
|
|
<ul>
|
|
<li>Go to <b>Settings → Integrations</b> to connect</li>
|
|
<li>Browse repo file trees in the sidebar</li>
|
|
<li>Create & edit files with commit messages</li>
|
|
<li>Sync labels as tags</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
|
|
<p>Share pages with collaborators or publish them to the web.</p>
|
|
<ul>
|
|
<li>Click <b>Share</b> in the page editor top-right</li>
|
|
<li>Share with specific users or get a public link</li>
|
|
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
|
|
<p>Find all your content in one place with powerful filtering.</p>
|
|
<ul>
|
|
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
|
|
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
|
|
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="help-card">
|
|
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
|
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
|
<ul>
|
|
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
|
<li>Edits made offline are queued locally and synced automatically</li>
|
|
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>⌨️ Keyboard Shortcuts</h2>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>🔐 Authentication</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
FlowDeck supports three authentication methods:<br>
|
|
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
|
|
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
|
|
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
|
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
|
</p>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
|
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
|
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
|
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
|
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
|
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
|
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
|
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
|
</p>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>📶 Offline mode (PWA)</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
|
edits are saved locally, then synchronised when the connection returns.<br><br>
|
|
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
|
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
|
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
|
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
|
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
|
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
|
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
|
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
|
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
|
page. If a page with the same title already exists, the offline copy is renamed
|
|
<i>“Title (copie offline)”</i>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>🔌 API publique v2</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
|
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
|
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
|
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
|
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
|
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
|
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
|
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
|
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
|
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
|
</p>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h2>💡 Tips</h2>
|
|
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
|
|
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
|
|
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
|
|
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
|
|
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
|
|
</p>
|
|
</div>
|
|
|
|
</div>"""
|
|
))
|
|
|
|
|
|
@router.get("/accounts/settings", response_class=HTMLResponse)
|
|
async def settings_page(request: Request):
|
|
"""User settings page — profile, forges, tokens."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
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
|
|
|
|
|
|
def _require_user_id(request: Request) -> int:
|
|
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
|
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(401, "Authentication required")
|
|
return user["id"]
|
|
|
|
|
|
@router.put("/api/user/profile")
|
|
async def update_profile(request: Request):
|
|
body = await request.json()
|
|
full_name = body.get("full_name", "").strip()
|
|
uid = _require_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):
|
|
from app.password_utils import hash_password, verify_password
|
|
body = await request.json()
|
|
password = body.get("password", "").strip()
|
|
if len(password) < 6:
|
|
return {"error": "Password must be at least 6 characters"}
|
|
uid = _require_user_id(request)
|
|
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
|
current = body.get("current_password", "")
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
|
if not row or not verify_password(current, row["password_hash"]):
|
|
raise HTTPException(403, "Current password is incorrect")
|
|
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 = _require_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 = _require_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),
|
|
):
|
|
"""Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
|
|
# ── Not authenticated → show landing page ──
|
|
if not user:
|
|
# Allow through if DB is empty (fresh install)
|
|
try:
|
|
with get_conn() as conn:
|
|
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
if count == 0:
|
|
from app.templating import ENV
|
|
env = ENV
|
|
template = env.get_template("landing.html")
|
|
return template.render()
|
|
except Exception:
|
|
pass
|
|
from app.templating import ENV
|
|
env = ENV
|
|
template = env.get_template("landing.html")
|
|
return template.render()
|
|
|
|
# ── Authenticated ──
|
|
user_id = user.get("id", 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:
|
|
# Check if user has any workspace
|
|
try:
|
|
with get_conn() as conn:
|
|
ws_count = conn.execute(
|
|
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
|
|
).fetchone()[0]
|
|
if ws_count == 0:
|
|
# v5.2.0: first-launch → onboarding wizard
|
|
return RedirectResponse("/welcome", status_code=302)
|
|
except Exception:
|
|
pass
|
|
return RedirectResponse("/local-workspace", status_code=302)
|
|
|
|
# ── Gitea user → full dashboard ──
|
|
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 app.templating import ENV
|
|
env = ENV
|
|
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 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)
|
|
ctx = {**sidebar, "user": user}
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
|
template = env.get_template("workspace.html")
|
|
return template.render(**ctx)
|
|
|
|
|
|
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
|
async def gitea_workspace_page(request: Request):
|
|
"""Gitea workspace — browse repo files."""
|
|
import json
|
|
|
|
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)
|
|
owner = request.query_params.get("owner", "")
|
|
repo = request.query_params.get("repo", "")
|
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
|
ws_name = ws_key or "Gitea Workspace"
|
|
# Auto-create local workspace mirror for storing local files
|
|
local_ws_id = None
|
|
if ws_key:
|
|
with get_conn() as conn:
|
|
existing = conn.execute(
|
|
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
|
(user["id"], ws_key, "%gitea_repo%")
|
|
).fetchone()
|
|
if existing:
|
|
local_ws_id = existing["id"]
|
|
else:
|
|
c = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
|
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
|
|
)
|
|
local_ws_id = c.lastrowid
|
|
conn.execute(
|
|
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
|
(local_ws_id, user["id"], "admin")
|
|
)
|
|
conn.commit()
|
|
ctx = {
|
|
**sidebar,
|
|
"user": user,
|
|
"active_ws_name": ws_name,
|
|
"workspace_key": ws_key,
|
|
"gitea_workspace": True, # always true on this page
|
|
"gitea_owner": owner,
|
|
"gitea_repo": repo,
|
|
"workspace_name": ws_name,
|
|
"workspace_initial": repo[0].upper() if repo else "G",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
|
|
}
|
|
template = env.get_template("gitea_workspace.html")
|
|
resp = HTMLResponse(content=template.render(**ctx))
|
|
resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
|
|
return resp
|
|
|
|
|
|
@router.get("/api/workspace/projects")
|
|
async def list_workspace_projects(request: Request):
|
|
"""List all projects: built-in + Gitea + GitHub.
|
|
Uses the user's own Gitea token if connected, not the global admin token."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
|
|
builtin = []
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_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 = []
|
|
# Use per-user token if available, otherwise return empty
|
|
user_gitea = get_user_gitea_client(request) if user else None
|
|
if user_gitea:
|
|
try:
|
|
repos = await user_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):
|
|
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."""
|
|
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."""
|
|
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 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")
|
|
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, 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}")
|
|
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})
|
|
# 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}
|
|
|
|
|
|
def _file_page_disk_path(page: dict):
|
|
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
|
|
|
|
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
|
|
when the row is not a file page, references a non-textual/missing file, or
|
|
the path escapes the data root (path-traversal guard).
|
|
"""
|
|
if (page.get("content_format") or "") != "file":
|
|
return None
|
|
import json as _json
|
|
try:
|
|
meta = _json.loads(page.get("content", "{}"))
|
|
except (_json.JSONDecodeError, TypeError):
|
|
meta = {}
|
|
if not isinstance(meta, dict):
|
|
return None
|
|
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
|
if not rel or not rel.startswith("uploads/"):
|
|
return None
|
|
parts = rel.split("/")
|
|
if ".." in parts or "." in parts:
|
|
return None
|
|
import os as _os
|
|
from pathlib import Path
|
|
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
|
full = (root / rel).resolve()
|
|
try:
|
|
full.relative_to(root)
|
|
except ValueError:
|
|
return None
|
|
if not full.exists() or not full.is_file():
|
|
return None
|
|
filename = parts[-1] or page.get("title", "file")
|
|
mime = meta.get("mime_type") or "application/octet-stream"
|
|
size = meta.get("size") or 0
|
|
return (full, filename, mime, size)
|
|
|
|
|
|
@router.get("/api/pages/{page_id}/download")
|
|
async def download_page_file(page_id: int):
|
|
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
|
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")
|
|
async def page_file_content(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
|
|
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")
|
|
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, uid: int | None = None) -> 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, page_icon, "
|
|
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
|
"created_at, updated_at FROM pages "
|
|
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
|
"ORDER BY created_at DESC",
|
|
(ws_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
|
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
|
"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 = {}
|
|
favorited_ids = set()
|
|
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"],
|
|
})
|
|
if uid is not None:
|
|
fav_rows = conn.execute(
|
|
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
|
|
[uid, *all_ids],
|
|
).fetchall()
|
|
favorited_ids = {fr["page_id"] for fr in fav_rows}
|
|
|
|
tree = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
children = _build_tree_children(conn, r["id"], ws_id, uid)
|
|
|
|
# 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,
|
|
"page_icon": r["page_icon"] or "",
|
|
"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"], []),
|
|
"is_shared": bool(r["is_shared"]),
|
|
"favorited": r["id"] in favorited_ids,
|
|
})
|
|
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
|
|
|
|
|
|
def _nav_breadcrumb(conn, page_id: int) -> list:
|
|
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
|
|
|
|
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
|
|
current page (url = None). Every item has ``menu: True`` so the header can
|
|
open a sibling-navigation dropdown for it.
|
|
"""
|
|
from app.routers.board import _file_icon
|
|
chain = []
|
|
current = page_id
|
|
seen = set()
|
|
while current and current not in seen:
|
|
seen.add(current)
|
|
row = conn.execute(
|
|
"SELECT id, title, parent_id, parent_section, content_format "
|
|
"FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(current,),
|
|
).fetchone()
|
|
if not row:
|
|
break
|
|
is_folder = row["parent_section"] == "Workspace"
|
|
title = row["title"] or "Untitled"
|
|
chain.insert(0, {
|
|
"id": row["id"],
|
|
"label": title,
|
|
"url": None,
|
|
"icon": "folder" if is_folder else _file_icon(title, row["content_format"]),
|
|
"menu": True,
|
|
})
|
|
current = row["parent_id"]
|
|
# All items except the current page are navigable links.
|
|
for i, item in enumerate(chain):
|
|
if i < len(chain) - 1:
|
|
item["url"] = f"/pages/{item['id']}"
|
|
return chain
|
|
|
|
|
|
@router.get("/api/nav/menu")
|
|
async 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")
|
|
async def create_local_workspace_item(request: Request):
|
|
"""Create a new file in the active workspace."""
|
|
body = await request.json()
|
|
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}")
|
|
async def rename_local_workspace_item(request: Request, item_id: int):
|
|
"""Rename a file."""
|
|
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):
|
|
"""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.utcnow().isoformat(), item_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
|
async 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}")
|
|
async def serve_uploaded_file(ws_id: int, filename: str):
|
|
"""Serve an uploaded file from disk."""
|
|
import mimetypes
|
|
import os
|
|
from pathlib import Path
|
|
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
|
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")
|
|
async def move_local_workspace_item(request: Request, item_id: int):
|
|
"""Move an item to a new parent (drag & drop)."""
|
|
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, user_id=_get_user_id(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, user_id=_get_user_id(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, user_id: int = None) -> dict | None:
|
|
"""Get the active workspace ID from the cookie (verified for current user), or first user 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:
|
|
ws_dict = dict(ws)
|
|
# Verify ownership — only return if it belongs to the current user
|
|
if user_id is None or ws_dict.get("owner_id") == user_id:
|
|
return ws_dict
|
|
except (ValueError, Exception):
|
|
pass
|
|
# Fallback: first workspace owned by this user
|
|
if user_id:
|
|
with get_conn() as conn:
|
|
ws = conn.execute(
|
|
"SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
|
|
(user_id,)
|
|
).fetchone()
|
|
if ws:
|
|
return dict(ws)
|
|
return None
|
|
|
|
|
|
@router.get("/workspaces", response_class=HTMLResponse)
|
|
async def workspaces_page(request: Request):
|
|
"""Workspaces list page."""
|
|
from app.templating import ENV
|
|
env = ENV
|
|
sidebar = _sidebar_data(request, [], include_workspace=False)
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
|
ctx = {**sidebar, "user": user}
|
|
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
|
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 AND collection_row_id IS NULL) 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, user_id=_get_user_id(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."""
|
|
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."""
|
|
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 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)
|
|
ctx = {**sidebar, "user": user}
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
|
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."""
|
|
import os
|
|
import uuid
|
|
from pathlib import Path
|
|
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/settings/avatar/{filename:path}")
|
|
async def serve_avatar_file(filename: str):
|
|
"""Serve an uploaded avatar image file."""
|
|
from pathlib import Path
|
|
|
|
from fastapi.responses import FileResponse
|
|
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
|
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
|
base_dir = Path("/data/avatars").resolve()
|
|
filepath = (base_dir / filename).resolve()
|
|
try:
|
|
filepath.relative_to(base_dir)
|
|
except ValueError:
|
|
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
|
if not filepath.is_file():
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
return FileResponse(filepath)
|
|
|
|
|
|
@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."""
|
|
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 = '', avatar_color = ? WHERE id = ?", (color, user["id"]))
|
|
conn.commit()
|
|
return {"status": "ok", "color": color}
|
|
|
|
|
|
# ═══════════ Tag Management API (per-user) ═══════════
|
|
|
|
@router.post("/api/settings/tags")
|
|
async def create_tag_global(request: Request):
|
|
"""Create a tag for the current user."""
|
|
body = await request.json()
|
|
tag_name = body.get("name", "").strip().lower()
|
|
color = body.get("color", "#787774")
|
|
uid = _get_user_id(request)
|
|
if not tag_name or not uid:
|
|
return {"error": "Tag name required"}, 400
|
|
with get_conn() as conn:
|
|
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).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, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
|
|
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) — only if owned by user."""
|
|
body = await request.json()
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
if "name" in body:
|
|
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
|
|
if "color" in body:
|
|
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/api/settings/tags/{tag_id:int}")
|
|
async def delete_tag_global(tag_id: int, request: Request):
|
|
"""Delete a tag — only if owned by user."""
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
|
|
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/api/settings/tags/all")
|
|
async def list_all_tags_global(request: Request):
|
|
"""List current user's tags with counts."""
|
|
uid = _get_user_id(request)
|
|
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 "
|
|
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
|
(uid,),
|
|
).fetchall()
|
|
return JSONResponse(
|
|
{"tags": [dict(r) for r in rows]},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
# ═══════════ Workspace Tags API ═══════════
|
|
|
|
@router.get("/api/local-workspace/tags")
|
|
async def list_tags(request: Request):
|
|
"""List ALL user tags with counts scoped to the active workspace."""
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
uid = _get_user_id(request)
|
|
if not ws_id:
|
|
return {"tags": []}
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.name, t.color, "
|
|
"(SELECT COUNT(*) FROM page_tags pt "
|
|
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
|
" WHERE pt.tag_id = t.id) as count "
|
|
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
|
|
(ws_id, uid),
|
|
).fetchall()
|
|
return JSONResponse(
|
|
{"tags": [dict(r) for r in rows]},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
@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, scoped to user)."""
|
|
body = await request.json()
|
|
tag_name = body.get("name", "").strip().lower()
|
|
tag_color = body.get("color", "#787774")
|
|
uid = _get_user_id(request)
|
|
if not tag_name:
|
|
return {"error": "Tag name required"}, 400
|
|
|
|
with get_conn() as conn:
|
|
# Get or create tag (per user)
|
|
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
|
if not tag:
|
|
cursor = conn.execute(
|
|
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
|
|
)
|
|
conn.commit()
|
|
tag_id = cursor.lastrowid
|
|
tag = {"id": tag_id, "name": tag_name, "color": tag_color}
|
|
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, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return {"items": []}
|
|
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}
|
|
|
|
|
|
# ── Account update ──
|
|
@router.put("/api/settings/account")
|
|
async def update_account(request: Request):
|
|
"""Update current user's profile: full_name, login, email, password."""
|
|
from app.password_utils import hash_password
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
with get_conn() as conn:
|
|
uid = user["id"]
|
|
if "full_name" in body:
|
|
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
|
|
if "login" in body:
|
|
new_login = body["login"].strip()
|
|
if new_login and new_login != user.get("login"):
|
|
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
|
|
if existing:
|
|
return JSONResponse({"error": "Username already taken"}, status_code=409)
|
|
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
|
|
if "email" in body:
|
|
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
|
|
if "password" in body and body["password"].strip():
|
|
pw = body["password"].strip()
|
|
if len(pw) < 6:
|
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
|
user_data = dict(row)
|
|
# Refresh session cookie with updated data (keeps the same session id)
|
|
cookie = request.cookies.get("flowdeck_session", "")
|
|
new_session = SessionManager.refresh_session(cookie, user_data, request)
|
|
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
|
|
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
|
return response
|
|
|
|
|
|
# ═══════════ Sidebar Refresh API ═══════════
|
|
|
|
@router.get("/api/sidebar/workspace-tree")
|
|
async def sidebar_workspace_tree(request: Request):
|
|
"""Return the sidebar workspace tree as HTML fragment.
|
|
|
|
Called by appState().refreshSidebarTree() after CRUD operations
|
|
in the main content area to keep the sidebar in sync.
|
|
"""
|
|
from app.routers.board import _load_workspace_pages
|
|
from app.templating import ENV
|
|
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return HTMLResponse("")
|
|
|
|
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
|
if not ws_cookie:
|
|
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
|
|
|
# Gitea workspace — no server-side tree, loaded client-side
|
|
if ws_cookie.startswith("gitea:"):
|
|
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
|
|
|
|
try:
|
|
ws_id = int(ws_cookie)
|
|
with get_conn() as conn:
|
|
# Verify workspace belongs to user
|
|
row = conn.execute(
|
|
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
|
(ws_id, user["id"])
|
|
).fetchone()
|
|
if not row:
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
pages = _load_workspace_pages(ws_cookie)
|
|
if not pages:
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
# Render the tree using the extracted macro
|
|
env = ENV
|
|
template = env.from_string(
|
|
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
|
"{{ render_workspace_tree(pages) }}"
|
|
)
|
|
html = template.render(pages=pages)
|
|
return HTMLResponse(html)
|
|
except (ValueError, Exception) as e:
|
|
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
|
|
# ═══════════ Public Published Page ═══════════
|
|
|
|
|
|
@router.get("/p/{slug}", response_class=HTMLResponse)
|
|
async def public_published_page(request: Request, slug: str):
|
|
"""Serve a published page at /p/<slug> — no auth required."""
|
|
from app.templating import ENV
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon "
|
|
"FROM pages WHERE publish_slug=? AND is_published=1",
|
|
(slug,),
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return HTMLResponse(
|
|
"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
|
<title>Not Found — FlowDeck</title>
|
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
|
|
justify-content:center;height:100vh;margin:0;background:#191919;color:#ccc;}
|
|
h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
|
status_code=404,
|
|
)
|
|
|
|
page = dict(row)
|
|
env = ENV
|
|
|
|
# Convert blocks to HTML for rendering
|
|
content_html = ""
|
|
if page.get("content_format") == "blocks" and page.get("content"):
|
|
import json as _json
|
|
try:
|
|
blocks = _json.loads(page["content"])
|
|
from app.services.synced_blocks import resolve_synced_block
|
|
blocks = resolve_synced_block(blocks)
|
|
from app.db import get_conn as _gc
|
|
from app.services.wiki_links import token_labels
|
|
with _gc() as conn:
|
|
wiki_titles_map = token_labels(conn, page["content"])
|
|
content_html = _render_blocks_public(blocks, wiki_titles_map)
|
|
except (_json.JSONDecodeError, Exception):
|
|
content_html = f"<p>{page.get('content', '')}</p>"
|
|
elif page.get("content"):
|
|
# Plain text / markdown
|
|
text = page["content"]
|
|
content_html = f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:16px;line-height:1.6;'>{text}</pre>"
|
|
|
|
template = env.get_template("public_page.html")
|
|
return template.render(
|
|
title=page["title"] or "Untitled",
|
|
content_html=content_html,
|
|
updated_at=page.get("updated_at", ""),
|
|
created_at=page.get("created_at", ""),
|
|
cover_url=page.get("cover_url", ""),
|
|
page_icon=page.get("page_icon", ""),
|
|
)
|
|
|
|
|
|
def _sanitize_id(block_id: str) -> str:
|
|
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
|
|
if not block_id:
|
|
return ""
|
|
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
|
|
|
|
|
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
|
"""Render FlowDeck blocks as plain HTML for public pages.
|
|
|
|
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
|
|
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
|
|
"""
|
|
html_parts = []
|
|
|
|
def _wiki(c: str) -> str:
|
|
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
|
|
from app.services.wiki_links import resolve_tokens_html
|
|
return resolve_tokens_html(c, titles)
|
|
return c
|
|
|
|
for b in blocks:
|
|
t = b.get("type", "paragraph")
|
|
c = _wiki(b.get("content", "") or "")
|
|
if t == "heading_1":
|
|
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
|
elif t == "heading_2":
|
|
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
|
|
elif t == "heading_3":
|
|
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
|
|
elif t == "heading_4":
|
|
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
|
|
elif t == "bulleted_list":
|
|
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
|
|
elif t == "numbered_list":
|
|
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
|
|
elif t == "to_do":
|
|
checked = "checked" if b.get("checked") else ""
|
|
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
|
|
html_parts.append(
|
|
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
|
|
f'<input type="checkbox" {checked} disabled>'
|
|
f'<span style="{todo_style}">{c}</span>'
|
|
f'</div>'
|
|
)
|
|
elif t == "toggle":
|
|
children_html = ""
|
|
if b.get("children"):
|
|
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
|
|
children_html += _render_blocks_public(b["children"], titles)
|
|
children_html += "</div>"
|
|
html_parts.append(
|
|
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
|
|
)
|
|
elif t == "quote":
|
|
html_parts.append(
|
|
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
|
|
)
|
|
elif t == "table_of_contents":
|
|
toc = [
|
|
x for x in blocks
|
|
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()
|
|
]
|
|
if toc:
|
|
items = []
|
|
for h in toc:
|
|
lvl = int(h["type"].split("_")[-1])
|
|
items.append(
|
|
f'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
|
|
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
|
|
)
|
|
html_parts.append(
|
|
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
|
|
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
|
|
+ "".join(items) + "</div>"
|
|
)
|
|
elif t == "math":
|
|
tex = c.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
html_parts.append(
|
|
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
|
|
)
|
|
elif t == "columns":
|
|
cols_html = ""
|
|
for child in b.get("children") or []:
|
|
cols_html += (
|
|
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
|
'border-radius:8px;box-sizing:border-box;">'
|
|
+ _render_blocks_public([child], titles) + "</div>"
|
|
)
|
|
html_parts.append(
|
|
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
|
|
)
|
|
elif t == "callout":
|
|
icon = b.get("icon", "💡")
|
|
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
|
|
html_parts.append(
|
|
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
|
|
f'background:{bg};align-items:flex-start;">'
|
|
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
|
|
f'<span>{c}</span></div>'
|
|
)
|
|
elif t == "code":
|
|
lang = b.get("language", "")
|
|
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
|
html_parts.append(
|
|
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
|
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
|
f'{lang_label}'
|
|
f'<code>{c}</code></pre>'
|
|
)
|
|
elif t == "divider":
|
|
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
|
elif t == "image":
|
|
src = b.get("src", "")
|
|
alt = b.get("alt", "")
|
|
html_parts.append(
|
|
f'<figure style="margin:16px 0;text-align:center;">'
|
|
f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
|
|
f'</figure>'
|
|
)
|
|
elif t == "video":
|
|
src = b.get("src", "")
|
|
if src:
|
|
html_parts.append(
|
|
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
|
|
f'<source src="{src}"></video>'
|
|
)
|
|
elif t == "audio":
|
|
src = b.get("src", "")
|
|
if src:
|
|
html_parts.append(
|
|
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
|
|
)
|
|
elif t == "bookmark":
|
|
url = b.get("url") or b.get("src") or ""
|
|
title = b.get("title") or url
|
|
desc = b.get("description") or ""
|
|
img = b.get("image") or ""
|
|
site = b.get("site_name") or ""
|
|
img_html = (
|
|
f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
|
)
|
|
desc_html = f'<div style="font-size:13px;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
|
|
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
|
html_parts.append(
|
|
f'<a href="{url}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
|
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid rgba(255,255,255,.12);border-radius:10px;'
|
|
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
|
|
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
|
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
|
)
|
|
elif t == "embed":
|
|
url = b.get("src", "")
|
|
emb = b.get("embed_type") or ""
|
|
if emb in ("inline_dbs", "collection"):
|
|
html_parts.append('<div>[Embedded content]</div>')
|
|
elif emb == "download":
|
|
html_parts.append(
|
|
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
|
|
)
|
|
elif emb == "pdf" and url:
|
|
html_parts.append(
|
|
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
|
|
)
|
|
elif url:
|
|
from app.services.embeds import embed_src
|
|
src = b.get("embed_src") or embed_src(url) or url
|
|
height = b.get("height") or 520
|
|
try:
|
|
height = int(height)
|
|
except (ValueError, TypeError):
|
|
height = 520
|
|
html_parts.append(
|
|
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
|
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
|
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
|
)
|
|
elif t == "synced":
|
|
# v6.5.0: render synced block instances (resolved server-side).
|
|
if b.get("_synced_deleted"):
|
|
html_parts.append(
|
|
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
|
|
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
|
|
'Deleted synced block</div>'
|
|
)
|
|
else:
|
|
inner = b.get("_synced_content")
|
|
if not isinstance(inner, list) or not inner:
|
|
try:
|
|
import json as _sj
|
|
parsed = _sj.loads(b.get("content") or "[]")
|
|
inner = parsed if isinstance(parsed, list) else []
|
|
except (ValueError, TypeError):
|
|
inner = []
|
|
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
|
|
for x in inner]
|
|
if inner:
|
|
html_parts.append(
|
|
'<div style="margin:8px 0;padding-left:12px;'
|
|
'border-left:3px solid var(--accent,#4c9aff);">'
|
|
+ _render_blocks_public(inner, titles) + '</div>'
|
|
)
|
|
else:
|
|
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
|
return "\n".join(html_parts)
|
|
|
|
|
|
# ═══════════ Library page actions API ═══════════
|
|
|
|
@router.get("/api/pages/{page_id:int}/content")
|
|
async def api_page_content(page_id: int):
|
|
"""Get page content for side peek preview."""
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT 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)
|
|
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
|
from app.services.synced_blocks import resolve_content_json
|
|
return {
|
|
"title": row["title"],
|
|
"content": resolve_content_json(row["content"], row["content_format"]),
|
|
"format": row["content_format"] or "blocks",
|
|
}
|
|
|
|
|
|
@router.put("/api/pages/{page_id:int}/rename")
|
|
async def api_rename_page(page_id: int, request: Request):
|
|
"""Inline rename a page title."""
|
|
body = await request.json()
|
|
title = (body.get("title") or "").strip()
|
|
if not title:
|
|
return JSONResponse({"error": "Title required"}, status_code=400)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
|
(title, page_id),
|
|
)
|
|
# v6.5.0: renaming a database row's content page updates the row.
|
|
from app.services.row_pages import sync_page_title_to_row
|
|
sync_page_title_to_row(conn, page_id)
|
|
conn.commit()
|
|
return {"status": "ok", "title": title}
|
|
|
|
|
|
@router.post("/api/pages/{page_id:int}/trash")
|
|
async def api_trash_page(page_id: int):
|
|
"""Soft-delete a page (move to trash)."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(page_id,),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
|
async def api_convert_to_database(page_id: int, request: Request):
|
|
"""Convert a page into a full-page database (Notion-style).
|
|
|
|
Creates a collection linked to this page, adds the default 'Name' property,
|
|
and sets the page's content_format to 'collection'.
|
|
"""
|
|
import json as _json
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
db_name = (body.get("name") or "").strip()
|
|
|
|
with get_conn() as conn:
|
|
page = conn.execute(
|
|
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not page:
|
|
return JSONResponse({"error": "Page not found"}, status_code=404)
|
|
|
|
if not db_name:
|
|
db_name = page["title"] or "New Database"
|
|
|
|
# Create the collection
|
|
cur = conn.execute(
|
|
"""INSERT INTO collections
|
|
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
|
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
|
|
(db_name, page_id, page["workspace_id"]),
|
|
)
|
|
collection_id = cur.lastrowid
|
|
|
|
# Create default "Name" property (text, position 0)
|
|
conn.execute(
|
|
"""INSERT INTO collection_properties
|
|
(collection_id, name, prop_type, position, required, visible_in_views)
|
|
VALUES (?, 'Name', 'title', 0, 1, 1)""",
|
|
(collection_id,),
|
|
)
|
|
|
|
# Create default "Table" view
|
|
conn.execute(
|
|
"""INSERT INTO collection_views
|
|
(collection_id, name, view_type, config_json, position)
|
|
VALUES (?, 'Table', 'table', ?, 0)""",
|
|
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
|
|
)
|
|
|
|
# Update the page to be a database page
|
|
conn.execute(
|
|
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(collection_id, page_id),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return {
|
|
"status": "converted",
|
|
"collection_id": collection_id,
|
|
"name": db_name,
|
|
"view_url": f"/pages/{page_id}",
|
|
}
|
|
|
|
|
|
@router.get("/api/collections/{collection_id:int}/table-data")
|
|
async def api_collection_table_data(collection_id: int):
|
|
"""Get collection properties + pages for rendering the table view."""
|
|
with get_conn() as conn:
|
|
coll = conn.execute(
|
|
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
|
).fetchone()
|
|
if not coll:
|
|
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
|
|
|
properties = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
|
|
pages = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
|
|
views = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
|
|
return {
|
|
"collection": dict(coll),
|
|
"properties": properties,
|
|
"pages": pages,
|
|
"views": views,
|
|
}
|
|
|
|
|
|
@router.post("/api/collections/{collection_id:int}/pages")
|
|
async def api_create_collection_page(collection_id: int, request: Request):
|
|
"""Create a new page (row) in a collection."""
|
|
import json as _json
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
title = body.get("title", "New page").strip() or "New page"
|
|
icon = body.get("icon", "file")
|
|
|
|
with get_conn() as conn:
|
|
coll = conn.execute(
|
|
"SELECT id FROM collections WHERE id=?", (collection_id,)
|
|
).fetchone()
|
|
if not coll:
|
|
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
|
|
|
# Get next position
|
|
max_pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
|
(collection_id,),
|
|
).fetchone()[0]
|
|
|
|
# Load default property values from collection properties
|
|
props = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
default_values = {}
|
|
for p in props:
|
|
if p["prop_type"] == "title":
|
|
default_values[str(p["id"])] = title
|
|
# Caller-provided values (e.g. board "add card in column X") win.
|
|
incoming = body.get("properties") or {}
|
|
if isinstance(incoming, dict):
|
|
default_values.update(incoming)
|
|
|
|
from app.services.property_types import apply_auto_properties
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
|
|
apply_auto_properties(props, default_values, user, is_create=True)
|
|
|
|
cur = conn.execute(
|
|
"""INSERT INTO collection_pages
|
|
(collection_id, title, icon, cover_url, position, property_values_json)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
|
|
_json.dumps(default_values)),
|
|
)
|
|
page_id = cur.lastrowid
|
|
conn.commit()
|
|
|
|
return {
|
|
"id": page_id,
|
|
"title": title,
|
|
"icon": icon,
|
|
"position": max_pos,
|
|
"property_values_json": default_values,
|
|
"status": "created",
|
|
}
|