feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""FlowDeck — unified audit log API (v7.2.0).
|
||||
|
||||
Merges ``api_audit_log`` + ``permission_audit_log`` + ``sso_login_history``
|
||||
with actor/resource/date filters and CSV export (10k rows max, 365-day
|
||||
retention note). Admin only. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import (
|
||||
has_scope,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["audit"])
|
||||
|
||||
|
||||
def _admin_user(request: Request) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
|
||||
(sess.get("id"),)).fetchone()
|
||||
if row and row["is_admin"]:
|
||||
return sess
|
||||
raise HTTPException(403, "Admin required")
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if user and user.get("is_admin") and has_scope(
|
||||
user.get("_token_scopes") or "read", "admin"):
|
||||
return user
|
||||
raise HTTPException(401, "Admin authentication required")
|
||||
|
||||
|
||||
def _query(source: str, actor: str, action: str, limit: int, offset: int):
|
||||
"""One source query → (rows, columns). All normalized to a common shape."""
|
||||
with get_conn() as conn:
|
||||
if source in ("api", "all"):
|
||||
rows = conn.execute(
|
||||
"""SELECT created_at AS at, user_id AS actor, action,
|
||||
resource_type || ':' || resource_id AS resource,
|
||||
ip_address AS ip, detail, 'api' AS source
|
||||
FROM api_audit_log
|
||||
WHERE (?='' OR CAST(user_id AS TEXT)=?)
|
||||
AND (?='' OR action LIKE ?)
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
||||
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
|
||||
).fetchall()
|
||||
if source == "api":
|
||||
return rows
|
||||
api = [dict(r) for r in rows]
|
||||
else:
|
||||
api = []
|
||||
if source in ("permissions", "all"):
|
||||
rows = conn.execute(
|
||||
"""SELECT created_at AS at, performed_by AS actor, action,
|
||||
resource_type || ':' || resource_id AS resource,
|
||||
ip_address AS ip,
|
||||
('target=' || COALESCE(target_user_id, target_group_id, '')
|
||||
|| ' ' || COALESCE(old_role,'') || '→' || COALESCE(new_role,'')) AS detail,
|
||||
'permissions' AS source
|
||||
FROM permission_audit_log
|
||||
WHERE (?='' OR CAST(performed_by AS TEXT)=?)
|
||||
AND (?='' OR action LIKE ?)
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
||||
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
|
||||
).fetchall()
|
||||
if source == "permissions":
|
||||
return rows
|
||||
perm = [dict(r) for r in rows]
|
||||
else:
|
||||
perm = []
|
||||
if source in ("sso", "all"):
|
||||
rows = conn.execute(
|
||||
"""SELECT created_at AS at, user_id AS actor,
|
||||
('sso_' || provider_type || '_' ||
|
||||
CASE success WHEN 1 THEN 'success' ELSE 'failure' END) AS action,
|
||||
provider_name AS resource, ip_address AS ip,
|
||||
COALESCE(error_message, sso_identifier, '') AS detail,
|
||||
'sso' AS source
|
||||
FROM sso_login_history
|
||||
WHERE (?='' OR CAST(user_id AS TEXT)=?)
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
||||
(actor, actor, limit, offset)).fetchall()
|
||||
if source == "sso":
|
||||
return rows
|
||||
sso = [dict(r) for r in rows]
|
||||
else:
|
||||
sso = []
|
||||
merged = sorted(api + perm + sso, key=lambda d: str(d.get("at") or ""),
|
||||
reverse=True)
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
@router.get("/api/v2/audit/logs")
|
||||
async def audit_logs(request: Request):
|
||||
_admin_user(request)
|
||||
qp = request.query_params
|
||||
source = (qp.get("source") or "all").lower()
|
||||
if source not in ("all", "api", "permissions", "sso"):
|
||||
raise HTTPException(400, "source must be all|api|permissions|sso")
|
||||
limit, offset = parse_pagination(request, default_limit=50, max_limit=500)
|
||||
rows = _query(source, qp.get("actor") or "", qp.get("action") or "", limit, offset)
|
||||
rows = [dict(r) if not isinstance(r, dict) else r for r in rows]
|
||||
if qp.get("format") == "csv":
|
||||
import csv
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=["at", "source", "actor", "action",
|
||||
"resource", "ip", "detail"])
|
||||
writer.writeheader()
|
||||
for r in rows[:10000]:
|
||||
writer.writerow({k: r.get(k, "") for k in writer.fieldnames})
|
||||
return PlainTextResponse(buf.getvalue(), media_type="text/csv",
|
||||
headers={"Content-Disposition":
|
||||
"attachment; filename=audit.csv"})
|
||||
return JSONResponse(content={"logs": rows, "source": source,
|
||||
"limit": limit, "offset": offset})
|
||||
@@ -318,12 +318,32 @@ async def local_login(request: Request):
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# v7.2.0: verified domain with SSO enforcement (admins keep local access).
|
||||
if not ud.get("is_admin"):
|
||||
with get_conn() as conn:
|
||||
dom = (ud.get("email") or "").split("@")[-1].lower() if "@" in (ud.get("email") or "") else ""
|
||||
if dom:
|
||||
enforced = conn.execute(
|
||||
"SELECT id FROM domain_claims WHERE domain=? AND verified=1"
|
||||
" AND enforce_sso=1", (dom,)).fetchone()
|
||||
if enforced:
|
||||
return JSONResponse(
|
||||
{"error": "Local login is disabled for your domain — sign in with SSO"},
|
||||
status_code=403)
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# v7.2.0: TOTP 2FA — password OK, but hold the session until code check.
|
||||
from app.services import two_factor as _2fa
|
||||
if _2fa.is_enabled(ud["id"]):
|
||||
return JSONResponse({"status": "2fa_required",
|
||||
"pending": _2fa.mint_pending(ud["id"])})
|
||||
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
@@ -458,6 +478,87 @@ async def current_user(request: Request):
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
if not _2fa.verify_code(user_id, body.get("code", "")):
|
||||
return JSONResponse({"error": "Invalid code"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
ud = dict(row)
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
def _session_user_or_401(request: Request) -> dict:
|
||||
from fastapi import HTTPException
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def twofa_status(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||
"backup_remaining": _2fa.remaining_backup_codes(user["id"])}
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return _2fa.setup_secret(user["id"])
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid code — secret not activated"},
|
||||
status_code=400)
|
||||
return {"status": "enabled", "backup_codes": codes}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
_2fa.disable(user["id"])
|
||||
return {"status": "disabled"}
|
||||
|
||||
# ── Helpers ──
|
||||
def _log_login(user_id: int, request: Request):
|
||||
"""Record login in history."""
|
||||
|
||||
+142
-1
@@ -8,7 +8,13 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import get_page_context, run_automation
|
||||
from app.services.automations import (
|
||||
get_page_context,
|
||||
get_steps,
|
||||
press_button,
|
||||
run_automation,
|
||||
validate_step,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["automations"])
|
||||
@@ -172,3 +178,138 @@ async def automation_runs_history(request: Request, auto_id: int, limit: int = 5
|
||||
(auto_id, limit),
|
||||
).fetchall()
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── v7.0.0 — chained steps (trigger/condition/delay/action) ───────────────
|
||||
|
||||
STEP_SECRET_FIELDS = {"webhook_url"}
|
||||
|
||||
|
||||
def _require_session(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _get_auto(auto_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _auto_404():
|
||||
# NOTE: return (not raise) — the global 404 handler redirects non-/api
|
||||
# paths to /workspaces, which TestClient follows into a 200.
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Automation not found"}, status_code=404)
|
||||
|
||||
|
||||
def _encrypt_step_config(config: dict) -> dict:
|
||||
"""Encrypt secret fields at rest (empty = keep existing, like sso_config)."""
|
||||
from app.services.sso_provisioning import encrypt_secret
|
||||
cfg = dict(config or {})
|
||||
for field in STEP_SECRET_FIELDS:
|
||||
if field in cfg and cfg[field]:
|
||||
val = str(cfg[field])
|
||||
if not val.startswith("gAAAAA"):
|
||||
cfg[field] = encrypt_secret(val)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/steps")
|
||||
async def list_steps(request: Request, auto_id: int):
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/steps")
|
||||
async def create_step(request: Request, auto_id: int):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
kind = body.get("kind", "")
|
||||
config = body.get("config", {}) or {}
|
||||
validate_step(kind, config)
|
||||
with get_conn() as conn:
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM automation_steps WHERE automation_id=?",
|
||||
(auto_id,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO automation_steps (automation_id, kind, position, config_json)"
|
||||
" VALUES (?,?,?,?)",
|
||||
(auto_id, kind, int(body.get("position", pos)),
|
||||
json.dumps(_encrypt_step_config(config))))
|
||||
conn.commit()
|
||||
step_id = cur.lastrowid
|
||||
return {"id": step_id, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/steps/{step_id}")
|
||||
async def update_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
|
||||
if not row:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Step not found"}, status_code=404)
|
||||
kind = body.get("kind", row["kind"])
|
||||
try:
|
||||
config = body.get("config", json.loads(row["config_json"] or "{}"))
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
config = {}
|
||||
validate_step(kind, config if isinstance(config, dict) else {})
|
||||
conn.execute(
|
||||
"UPDATE automation_steps SET kind=?, position=?, config_json=? WHERE id=?",
|
||||
(kind, int(body.get("position", row["position"])),
|
||||
json.dumps(_encrypt_step_config(config)), step_id))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/steps/{step_id}")
|
||||
async def delete_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
||||
conn.commit()
|
||||
return {"id": step_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}/mode")
|
||||
async def set_trigger_mode(request: Request, auto_id: int):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
mode = (body.get("mode") or "any").lower()
|
||||
if mode not in ("any", "all"):
|
||||
raise HTTPException(status_code=400, detail="mode must be any or all")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE automations SET trigger_mode=? WHERE id=?", (mode, auto_id))
|
||||
conn.commit()
|
||||
return {"id": auto_id, "trigger_mode": mode}
|
||||
|
||||
|
||||
@router.post("/api/automations/press-button")
|
||||
async def press_button_endpoint(request: Request):
|
||||
"""Run the automation linked to a native DB button cell (CSRF-exempt)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
collection_id = int(body.get("collection_id", 0))
|
||||
row_id = int(body.get("row_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="collection_id + row_id required") from None
|
||||
prop_ref = body.get("property", body.get("property_id", ""))
|
||||
if not prop_ref:
|
||||
raise HTTPException(status_code=400, detail="property required")
|
||||
user = _current_user(request)
|
||||
try:
|
||||
result = await press_button(collection_id, row_id, prop_ref, user.get("id") or 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
return result
|
||||
|
||||
+57
-3
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@@ -1456,7 +1457,8 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown"})
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -1522,7 +1524,8 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid})
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -1883,7 +1886,11 @@ async def import_file(request: Request):
|
||||
|
||||
@router.post("/api/og/metadata")
|
||||
async def og_metadata(request: Request):
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card."""
|
||||
"""v5.5.0: Open Graph metadata for a bookmark card.
|
||||
|
||||
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
|
||||
unfurled straight from the forge API (no HTTP fetch of the HTML page).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -1891,11 +1898,58 @@ async def og_metadata(request: Request):
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
m = _REPO_REF_RE.match(url)
|
||||
if m:
|
||||
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
|
||||
data = await _unfurl_repo(forge, owner, repo)
|
||||
if data:
|
||||
return {"ok": True, **data}
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
data = await fetch_og_metadata(url)
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
|
||||
|
||||
|
||||
async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
|
||||
try:
|
||||
if forge == "gitea":
|
||||
from app.services.gitea_client import GiteaClient
|
||||
info = await GiteaClient().get_repo_info(owner, repo)
|
||||
site = "Gitea"
|
||||
else:
|
||||
from app.config import settings
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = getattr(settings, "github_token", None) or ""
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
site = "GitHub"
|
||||
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
|
||||
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
|
||||
return None
|
||||
branch = info.get("default_branch") or "main"
|
||||
return {
|
||||
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
|
||||
"title": info.get("full_name") or f"{owner}/{repo}",
|
||||
"description": (info.get("description") or f"{site} repository "
|
||||
f"{owner}/{repo} · default branch: {branch}"),
|
||||
"image": "",
|
||||
"site_name": site,
|
||||
"language": info.get("language") or "",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
@@ -107,6 +107,12 @@ async def add_comment(request: Request, page_id: int):
|
||||
comment_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
# v7.3.0: commenting implies following — the author gets the
|
||||
# (throttled) page.updated notifications like any other follower.
|
||||
from app.services import wiki as wiki_svc
|
||||
wiki_svc.ensure_follow(page_id, uid, conn=conn)
|
||||
conn.commit()
|
||||
|
||||
# Notify users @-mentioned in the comment (skip the author).
|
||||
url = _page_url(page_id)
|
||||
title = f"New comment on “{page['title']}”"
|
||||
|
||||
+157
-12
@@ -1,6 +1,7 @@
|
||||
"""FlowDeck — Collections router: Notion-style databases (v1.3.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
@@ -1898,6 +1899,71 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
|
||||
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
|
||||
point at *any* database (the dashboard's own collection is the default),
|
||||
which is what "dashboards multi-DB" means.
|
||||
"""
|
||||
uid = _session_user(request)
|
||||
_require_view(collection_id, uid)
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
|
||||
(dashboard_id, collection_id)).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
layout = json.loads(dash["layout_json"] or "{}")
|
||||
columns = max(1, int(layout.get("columns", 1) or 1))
|
||||
widgets = layout.get("widgets", []) or []
|
||||
if not isinstance(widgets, list):
|
||||
widgets = []
|
||||
|
||||
rendered = []
|
||||
for w in widgets[:40]:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
wc = int(w.get("collection_id") or 0) or collection_id
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
|
||||
if not coll:
|
||||
continue
|
||||
try:
|
||||
_require_view(wc, uid)
|
||||
except HTTPException:
|
||||
continue # restricted database → widget skipped, not rendered
|
||||
with get_conn() as conn:
|
||||
wpages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
|
||||
(wc, CHART_MAX_GROUPS)).fetchall()
|
||||
wconfig = {k: v for k, v in w.items()
|
||||
if k in ("chart_type", "chart_property", "aggregate", "title")}
|
||||
view_type = w.get("view_type") or "chart"
|
||||
if view_type == "chart":
|
||||
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
else:
|
||||
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
width = int(w.get("width") or 0)
|
||||
span = f"grid-column: span {width};" if width and width > 0 else ""
|
||||
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
|
||||
|
||||
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
||||
body = f"""
|
||||
<style>
|
||||
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
||||
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
||||
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
||||
</style>
|
||||
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
||||
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
||||
"""
|
||||
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
@@ -1912,9 +1978,14 @@ async def view_collection(request: Request, collection_id: int, view_type: str =
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
|
||||
(collection_id, view_type),
|
||||
).fetchone()
|
||||
if not view:
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
@@ -2188,27 +2259,101 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
|
||||
|
||||
# ── v4.3.0: New view types ──
|
||||
|
||||
# Multi-collection dashboards and chart aggregations cap the number of
|
||||
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
|
||||
CHART_MAX_GROUPS = 200
|
||||
|
||||
|
||||
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
|
||||
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
|
||||
values: list[float] = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
v = props.get(chart_property)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return values
|
||||
|
||||
|
||||
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
|
||||
"""Compute count|sum|avg|min|max over a property (or row count)."""
|
||||
values = _chart_values(pages, chart_property)
|
||||
if aggregate == "count":
|
||||
return float(len(pages[:CHART_MAX_GROUPS]))
|
||||
if not values:
|
||||
return 0.0
|
||||
if aggregate == "sum":
|
||||
return float(sum(values))
|
||||
if aggregate == "avg":
|
||||
return float(sum(values) / len(values))
|
||||
if aggregate == "min":
|
||||
return float(min(values))
|
||||
if aggregate == "max":
|
||||
return float(max(values))
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
if abs(value) >= 1e9:
|
||||
return f"{value / 1e9:.2f}B"
|
||||
if abs(value) >= 1e6:
|
||||
return f"{value / 1e6:.2f}M"
|
||||
if abs(value) >= 1e3:
|
||||
return f"{value / 1e3:.1f}K"
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN."""
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
|
||||
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
|
||||
chart_type = config.get("chart_type", "bar")
|
||||
chart_property = config.get("chart_property", "")
|
||||
|
||||
if chart_type == "number":
|
||||
aggregate = config.get("aggregate", "sum" if chart_property else "count")
|
||||
if aggregate not in ("count", "sum", "avg", "min", "max"):
|
||||
aggregate = "sum" if chart_property else "count"
|
||||
num = _chart_aggregate(pages, chart_property, aggregate)
|
||||
label = config.get("title") or chart_property or collection["name"]
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
||||
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
||||
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
||||
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
||||
</style>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">{label}</div>
|
||||
<div class="kpi-value">{_fmt_number(num)}</div>
|
||||
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
||||
{' of ' + chart_property if chart_property else ''}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
for p in pages:
|
||||
labels.append(p["title"][:30])
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
val = 0
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
labels.append(str(p.get("title") or "")[:30])
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
val = 0.0
|
||||
if chart_property:
|
||||
v_raw = props.get(chart_property, 0)
|
||||
try:
|
||||
val = float(v_raw) if v_raw else 0
|
||||
val = float(v_raw) if v_raw else 0.0
|
||||
except (ValueError, TypeError):
|
||||
val = 0
|
||||
values.append(val if val else 1)
|
||||
val = 0.0
|
||||
values.append(val)
|
||||
|
||||
labels_json = json.dumps(labels)
|
||||
values_json = json.dumps(values)
|
||||
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
|
||||
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
@@ -2223,7 +2368,7 @@ new Chart(document.getElementById('chartCanvas'), {{
|
||||
data: {{
|
||||
labels: {labels_json},
|
||||
datasets: [{{
|
||||
label: '{collection["name"]}',
|
||||
label: '{config.get("title") or collection["name"]}',
|
||||
data: {values_json},
|
||||
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
||||
}}]
|
||||
@@ -2231,7 +2376,7 @@ new Chart(document.getElementById('chartCanvas'), {{
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{len(pages)} entries</p>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""FlowDeck — agent governance API (v7.2.0): policies + approval queue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import agent_policies as policies
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
router = APIRouter(tags=["governance"])
|
||||
|
||||
|
||||
def _owner_or_admin(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
is_admin = bool(row and row["is_admin"])
|
||||
if not is_admin and request.query_params.get("workspace_id"):
|
||||
member = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(request.query_params.get("workspace_id"), user["id"])).fetchone()
|
||||
owner = conn.execute("SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(request.query_params.get("workspace_id"),
|
||||
user["id"])).fetchone()
|
||||
if not member and not owner:
|
||||
raise HTTPException(403, "Workspace access required")
|
||||
if member and member["role"] not in ("admin", "editor", "owner"):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
user["is_admin"] = is_admin
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-policies")
|
||||
async def list_policies(request: Request):
|
||||
_owner_or_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
|
||||
return {"policies": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-policies")
|
||||
async def upsert_policy(request: Request):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
wid = body.get("workspace_id")
|
||||
tools = body.get("allowed_tools")
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
raise HTTPException(400, "allowed_tools must be a list or null")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO agent_policies (workspace_id, allowed_tools_json, max_steps,
|
||||
require_approval)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
allowed_tools_json=excluded.allowed_tools_json,
|
||||
max_steps=excluded.max_steps, require_approval=excluded.require_approval""",
|
||||
(wid, json.dumps(tools) if tools is not None else None,
|
||||
max(1, min(int(body.get("max_steps") or 12), 50)),
|
||||
1 if body.get("require_approval") else 0))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS ?",
|
||||
(wid,)).fetchone()
|
||||
audit_log(user, "agent.policy.upsert", "workspace", wid or 0, "", request)
|
||||
return JSONResponse(status_code=201, content=dict(row))
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-approvals")
|
||||
async def list_approvals(request: Request):
|
||||
_owner_or_admin(request)
|
||||
status = request.query_params.get("status", "pending")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_approvals WHERE status=? ORDER BY id DESC"
|
||||
" LIMIT 100", (status,)).fetchall()
|
||||
return {"approvals": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: int, request: Request):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
|
||||
if out is None:
|
||||
raise HTTPException(404, "Pending approval not found")
|
||||
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
|
||||
out["status"], request)
|
||||
return out
|
||||
@@ -0,0 +1,206 @@
|
||||
"""FlowDeck — Calendar links + Meetings API (v7.1.0).
|
||||
|
||||
``/api/v2/calendar-links*`` — Google/CalDAV link CRUD (session or Bearer
|
||||
``write``), manual sync trigger. ``GET /db/{id}/calendar/freebusy`` —
|
||||
weekday availability. ``/api/v2/meetings/*`` — audio upload, manual
|
||||
transcript, AI summary (fires ``meeting.summarized``).
|
||||
|
||||
See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import calendar_sync as cal
|
||||
from app.services import meetings as meet
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["calendar-meetings"])
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
# ── calendar links ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/calendar-links")
|
||||
async def create_link(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
provider = (body.get("provider") or "").lower()
|
||||
if provider not in cal.PROVIDERS:
|
||||
raise HTTPException(400, "provider must be google|caldav")
|
||||
try:
|
||||
collection_id = int(body.get("collection_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "collection_id required") from None
|
||||
creds = body.get("credentials") or {}
|
||||
if provider == "google" and not creds.get("access_token"):
|
||||
raise HTTPException(400, "google needs credentials.access_token")
|
||||
if provider == "caldav" and not creds.get("url"):
|
||||
raise HTTPException(400, "caldav needs credentials.url")
|
||||
try:
|
||||
out = cal.save_link(user["id"], provider, collection_id, creds,
|
||||
body.get("calendar_id") or "primary",
|
||||
body.get("date_property") or "")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "calendar.link", "collection", collection_id, provider, request)
|
||||
return JSONResponse(status_code=201, content=out)
|
||||
|
||||
|
||||
@router.get("/api/v2/calendar-links")
|
||||
async def get_links(request: Request):
|
||||
user = _auth_user(request)
|
||||
return {"links": cal.list_links(user["id"])}
|
||||
|
||||
|
||||
@router.delete("/api/v2/calendar-links/{link_id}")
|
||||
async def remove_link(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
if not cal.delete_link(user["id"], link_id):
|
||||
raise HTTPException(404, "Link not found")
|
||||
audit_log(user, "calendar.unlink", "calendar_link", link_id, "", request)
|
||||
return {"status": "deleted", "id": link_id}
|
||||
|
||||
|
||||
@router.post("/api/v2/calendar-links/{link_id}/sync")
|
||||
async def sync_now(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
|
||||
if not row or (row["user_id"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Link not found")
|
||||
try:
|
||||
stats = await cal.sync_link(link_id)
|
||||
except (cal.SyncError, ValueError) as exc:
|
||||
raise HTTPException(502 if isinstance(exc, cal.SyncError) else 400,
|
||||
str(exc)) from None
|
||||
audit_log(user, "calendar.sync", "calendar_link", link_id, str(stats), request)
|
||||
return {"link_id": link_id, **stats}
|
||||
|
||||
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/db/{collection_id}/calendar/freebusy")
|
||||
async def freebusy(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
qp = request.query_params
|
||||
try:
|
||||
out = cal.freebusy(collection_id, qp.get("from", ""), qp.get("to", ""),
|
||||
qp.get("date_property", ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
return out
|
||||
|
||||
|
||||
# ── meetings ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/meetings/transcribe")
|
||||
async def upload_and_transcribe(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
raise HTTPException(400, "multipart upload required") from None
|
||||
upload = form.get("audio")
|
||||
try:
|
||||
page_id = int(form.get("page_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "page_id required") from None
|
||||
language = (form.get("language") or "fr")[:10]
|
||||
manual = (form.get("transcript") or "").strip()
|
||||
if upload is None and not manual:
|
||||
raise HTTPException(400, "audio file or transcript required")
|
||||
audio_path = ""
|
||||
if upload is not None:
|
||||
filename = (upload.filename or "").lower()
|
||||
ext = filename.rsplit(".", 1)[-1] if "." in filename else ""
|
||||
if ext not in meet.AUDIO_EXTENSIONS:
|
||||
raise HTTPException(400, f"audio must be one of {sorted(meet.AUDIO_EXTENSIONS)}")
|
||||
data = await upload.read()
|
||||
if len(data) > meet.MAX_AUDIO_BYTES:
|
||||
raise HTTPException(413, "audio exceeds 100 MB")
|
||||
if not data:
|
||||
raise HTTPException(400, "empty audio file")
|
||||
audio_path = str(meet.meetings_dir()
|
||||
/ f"{page_id}_{secrets.token_hex(8)}.{ext}")
|
||||
with open(audio_path, "wb") as fh:
|
||||
fh.write(data)
|
||||
transcript = manual
|
||||
if not transcript and audio_path:
|
||||
try:
|
||||
transcript = meet.transcribe_audio(audio_path, language)
|
||||
except meet.TranscriptionUnavailable as exc:
|
||||
transcript = "" # stored; client transcribes or posts manual text later
|
||||
_ = exc
|
||||
try:
|
||||
tid = meet.save_transcript(page_id, transcript, language, audio_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "meeting.transcribe", "page", page_id, f"transcript={tid}", request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
**row_to_dict(row), "transcribed": bool(transcript)})
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
|
||||
async def set_transcript_text(transcript_id: int, request: Request):
|
||||
"""Store a client-side (manual) transcript on an existing row."""
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
text = (body.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "transcript required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone():
|
||||
raise HTTPException(404, "Transcript not found")
|
||||
conn.execute("UPDATE meeting_transcripts SET transcript=? WHERE id=?",
|
||||
(text, transcript_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
|
||||
async def summarize(transcript_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = await meet.summarize_transcript(transcript_id, user.get("id"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
@@ -0,0 +1,297 @@
|
||||
"""FlowDeck — SCIM 2.0 provisioning + domain claims (v7.2.0).
|
||||
|
||||
``/scim/v2/Users`` (Bearer ``scim_tokens``, admin) : IT systems provision and
|
||||
deprovision accounts. Suspend (``active=false``) flips ``users.is_active`` and
|
||||
revokes ``user_sessions``. Domain claims: ``/.well-known`` HTTP verification +
|
||||
optional local-login enforcement per email domain.
|
||||
|
||||
See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
router = APIRouter(tags=["scim"])
|
||||
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
|
||||
|
||||
# ── auth ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _scim_guard(request: Request) -> dict:
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
digest = hashlib.sha256(auth[7:].strip().encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM scim_tokens WHERE token_hash=? AND revoked=0",
|
||||
(digest,)).fetchone()
|
||||
if row:
|
||||
return {"scim_token_id": row["id"], "name": row["name"]}
|
||||
raise HTTPException(401, "SCIM token required")
|
||||
|
||||
|
||||
def _admin_session(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
|
||||
|
||||
def _scim_user(row) -> dict:
|
||||
d = dict(row)
|
||||
return {"schemas": SCIM_SCHEMAS, "id": str(d["id"]), "userName": d["login"],
|
||||
"name": {"formatted": d.get("full_name") or d["login"]},
|
||||
"emails": [{"value": d.get("email") or "", "primary": True}],
|
||||
"active": bool(d.get("is_active", 1)),
|
||||
"meta": {"resourceType": "User"}}
|
||||
|
||||
|
||||
# ── SCIM resources ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/scim/v2/Users")
|
||||
async def scim_list(request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
|
||||
items = [_scim_user(r) for r in rows]
|
||||
return {"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": len(items), "Resources": items}
|
||||
|
||||
|
||||
@router.post("/scim/v2/Users")
|
||||
async def scim_create(request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = (body.get("userName") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(400, "userName required")
|
||||
email = ""
|
||||
for em in body.get("emails") or []:
|
||||
if isinstance(em, dict) and em.get("value"):
|
||||
email = em["value"]
|
||||
break
|
||||
name = ((body.get("name") or {}).get("formatted") or username)[:200]
|
||||
active = body.get("active", True)
|
||||
with get_conn() as conn:
|
||||
if conn.execute("SELECT id FROM users WHERE login=?", (username,)).fetchone():
|
||||
raise HTTPException(409, "User already exists")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, is_active, auth_method)"
|
||||
" VALUES (?,?,?,?,'saml')",
|
||||
(username, name, email, 1 if active else 0))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||
return JSONResponse(status_code=201, content=_scim_user(row))
|
||||
|
||||
|
||||
@router.get("/scim/v2/Users/{user_id}")
|
||||
async def scim_get(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
def _apply_scim_update(conn, user_id: str, body: dict) -> None:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
updates: dict = {}
|
||||
if "userName" in body and body["userName"]:
|
||||
updates["login"] = body["userName"].strip()
|
||||
if isinstance(body.get("name"), dict) and body["name"].get("formatted"):
|
||||
updates["full_name"] = body["name"]["formatted"][:200]
|
||||
if isinstance(body.get("emails"), list):
|
||||
for em in body["emails"]:
|
||||
if isinstance(em, dict) and em.get("value"):
|
||||
updates["email"] = em["value"][:200]
|
||||
break
|
||||
if "active" in body:
|
||||
updates["is_active"] = 1 if body["active"] else 0
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE users SET {sets} WHERE id=?", (*updates.values(), user_id))
|
||||
if body.get("active") is False:
|
||||
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@router.put("/scim/v2/Users/{user_id}")
|
||||
async def scim_replace(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, body)
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
@router.patch("/scim/v2/Users/{user_id}")
|
||||
async def scim_patch(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
flat: dict = {}
|
||||
for op in body.get("Operations") or []:
|
||||
path = (op.get("path") or "").lower()
|
||||
if path in ("username", "active"):
|
||||
flat["userName" if path == "username" else "active"] = op.get("value")
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, {**body, **flat})
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
return _scim_user(row)
|
||||
|
||||
|
||||
@router.delete("/scim/v2/Users/{user_id}")
|
||||
async def scim_delete(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
# Deprovision = suspend (keeps content + audit trail).
|
||||
conn.execute("UPDATE users SET is_active=0 WHERE id=?", (user_id,))
|
||||
conn.execute("UPDATE user_sessions SET revoked=1 WHERE user_id=?", (user_id,))
|
||||
conn.commit()
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
|
||||
|
||||
# ── SCIM token management (admin, session) ─────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/scim/tokens")
|
||||
async def create_scim_token(request: Request):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
raw = f"scim_{secrets.token_urlsafe(32)}"
|
||||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO scim_tokens (token_hash, name, created_by)"
|
||||
" VALUES (?,?,?)",
|
||||
(digest, str(body.get("name") or "SCIM")[:120], admin["id"]))
|
||||
conn.commit()
|
||||
audit_log(admin, "scim.token.create", "scim_token", cur.lastrowid, "", request)
|
||||
return JSONResponse(status_code=201,
|
||||
content={"id": cur.lastrowid, "token": raw,
|
||||
"warning": "shown once"})
|
||||
|
||||
|
||||
@router.get("/api/v2/scim/tokens")
|
||||
async def list_scim_tokens(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
|
||||
" FROM scim_tokens ORDER BY id DESC").fetchall()
|
||||
return {"tokens": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/api/v2/scim/tokens/{token_id}")
|
||||
async def revoke_scim_token(token_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "scim.token.revoke", "scim_token", token_id, "", request)
|
||||
return {"status": "revoked", "id": token_id}
|
||||
|
||||
|
||||
# ── domain claims ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/domain-claims")
|
||||
async def list_domains(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d.pop("txt_token", None)
|
||||
out.append(d)
|
||||
return {"domains": out}
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims")
|
||||
async def create_domain(request: Request):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
domain = (body.get("domain") or "").strip().lower()
|
||||
if not domain or "." not in domain or "/" in domain:
|
||||
raise HTTPException(400, "valid domain required")
|
||||
token = f"flowdeck-verify={secrets.token_hex(16)}"
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO domain_claims
|
||||
(domain, txt_token, auto_join_role, enforce_sso, workspace_id)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(domain, token, body.get("auto_join_role") or "viewer",
|
||||
1 if body.get("enforce_sso") else 0, body.get("workspace_id")))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(409, "Domain already claimed") from None
|
||||
did = cur.lastrowid
|
||||
audit_log(admin, "domain.claim", "domain", did, domain, request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
"id": did, "domain": domain,
|
||||
"verify_url": f"https://{domain}/.well-known/flowdeck-verify.txt",
|
||||
"expected_content": token})
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims/{domain_id}/verify")
|
||||
async def verify_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
import httpx
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Domain not found")
|
||||
claim = dict(row)
|
||||
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
|
||||
except Exception: # noqa: BLE001 — unreachable domain = not verified
|
||||
ok = False
|
||||
if ok:
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE domain_claims SET verified=1 WHERE id=?", (domain_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "domain.verify", "domain", domain_id, str(ok), request)
|
||||
return {"id": domain_id, "verified": ok}
|
||||
|
||||
|
||||
@router.delete("/api/v2/domain-claims/{domain_id}")
|
||||
async def delete_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
|
||||
conn.commit()
|
||||
audit_log(admin, "domain.delete", "domain", domain_id, "", request)
|
||||
return {"status": "deleted", "id": domain_id}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""FlowDeck — hybrid search + Ask AI API (v6.9.0).
|
||||
|
||||
``GET /api/v2/search/hybrid`` — lexical (FTS5/LIKE) fused with vector cosine
|
||||
(RRF), workspace-scoped, ACL-filtered, paginated with ``X-Total-Count``.
|
||||
``POST /api/v2/search/ask`` — RAG answer with ``[[fdpage:ID]]`` citations
|
||||
(LLM when configured, extractive offline fallback), cached 10 min.
|
||||
|
||||
Auth: session cookie first, Bearer fallback (``read`` scope suffices).
|
||||
See ``docs/V69_Search_Ask_AI.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import semantic_search as sem
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["search-ai"])
|
||||
|
||||
|
||||
def _auth_user(request: Request) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if not has_scope(user.get("_token_scopes") or "read", "read"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: read")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
@router.get("/api/v2/search/hybrid")
|
||||
async def hybrid(request: Request):
|
||||
user = _auth_user(request)
|
||||
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
raise HTTPException(400, "q is required")
|
||||
limit, offset = parse_pagination(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
workspace_id = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
results, _total = sem.hybrid_search(q, user, limit=limit + offset,
|
||||
workspace_id=workspace_id)
|
||||
page = results[offset:offset + limit]
|
||||
# Index-on-read: a fresh page may not be indexed yet (scheduler runs every
|
||||
# 5 min). Best-effort is handled by tests calling index_resource directly.
|
||||
resp = JSONResponse({"query": q, "results": page,
|
||||
"total": len(results), "limit": limit, "offset": offset})
|
||||
for k, v in paginate_headers(len(results)).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/api/v2/search/ask")
|
||||
async def ask_ai(request: Request):
|
||||
user = _auth_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
question = (body.get("question") or body.get("q") or "").strip()
|
||||
if not question:
|
||||
raise HTTPException(400, "question is required")
|
||||
ws = body.get("workspace_id")
|
||||
workspace_id = int(ws) if isinstance(ws, int) or (isinstance(ws, str) and ws.isdigit()) else None
|
||||
out = await sem.ask(question, user, workspace_id)
|
||||
audit_log(user, "search.ask", "search", "", question[:200], request)
|
||||
return {"question": question, "workspace_id": workspace_id, **out}
|
||||
|
||||
|
||||
@router.get("/api/v2/search/index-status")
|
||||
async def index_status(request: Request):
|
||||
"""How many resources are indexed vs pending (owner/admin visibility)."""
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
indexed = conn.execute("SELECT COUNT(*) FROM semantic_index_state").fetchone()[0]
|
||||
vectors = conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0]
|
||||
pages_total = conn.execute(
|
||||
"SELECT COUNT(*) FROM pages WHERE (deleted_at IS NULL OR deleted_at='') "
|
||||
"AND COALESCE(search_excluded, 0)=0").fetchone()[0]
|
||||
return {"indexed_resources": indexed, "vectors": vectors,
|
||||
"indexable_pages": pages_total, "model": sem.MODEL, "dim": sem.DIM,
|
||||
"user_id": user.get("id")}
|
||||
@@ -27,9 +27,10 @@ DEFAULT_CONFIG = {
|
||||
"recents": {"visible": True, "order": 3, "show_count": 10},
|
||||
"favorites": {"visible": True, "order": 4, "show_count": 10},
|
||||
"agents": {"visible": True, "order": 5, "show_count": None},
|
||||
"shared": {"visible": True, "order": 6, "show_count": 10},
|
||||
"published": {"visible": True, "order": 7, "show_count": 10},
|
||||
"private": {"visible": True, "order": 8, "show_count": None},
|
||||
"teamspaces": {"visible": True, "order": 6, "show_count": None},
|
||||
"shared": {"visible": True, "order": 7, "show_count": 10},
|
||||
"published": {"visible": True, "order": 8, "show_count": 10},
|
||||
"private": {"visible": True, "order": 9, "show_count": None},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
"""FlowDeck — Sites & public Forms (v6.8.0).
|
||||
|
||||
Notion Sites + Forms parity: multi-page public sites (/s/<slug>) with nav,
|
||||
password/expiry gating, SEO + view stats, and anonymous collection forms
|
||||
(/f/<token>) with rate limiting, validation and notifications.
|
||||
|
||||
Auth: session cookie first, Bearer token fallback (api_tokens,
|
||||
extension_devices, legacy user_tokens) via api_v2_helpers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password, verify_password
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["sites"])
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$")
|
||||
_FORM_TOKEN_RE = re.compile(r"^f_[A-Za-z0-9_-]{6,64}$")
|
||||
|
||||
# In-memory rate limiting for anonymous form posts: ip -> (window_start, count).
|
||||
_form_rate: dict[str, tuple[float, int]] = {}
|
||||
_FORM_RATE_MAX = 20
|
||||
_FORM_RATE_WINDOW = 3600.0
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
def _check_slug(slug: str) -> None:
|
||||
if not _SLUG_RE.match(slug or ""):
|
||||
raise HTTPException(400, "Invalid slug: 3-50 chars, lowercase letters, digits, dashes.")
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
"""Session-first auth, Bearer fallback. Enforces scope for Bearer tokens."""
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
def _site_auth_cookie(site_id: int) -> str:
|
||||
return f"site_auth_{site_id}"
|
||||
|
||||
|
||||
def _site_unlocked(request: Request, site: dict) -> bool:
|
||||
if not site.get("password_hash"):
|
||||
return True
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
|
||||
try:
|
||||
val = ser.loads(request.cookies.get(_site_auth_cookie(site["id"]), ""), max_age=86400)
|
||||
return val == site["id"]
|
||||
except BadSignature:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _site_expired(site: dict) -> bool:
|
||||
exp = site.get("expires_at")
|
||||
if not exp:
|
||||
return False
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.timestamp() < time.time()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_site(conn, *, slug: str = "", host: str = "") -> dict | None:
|
||||
row = None
|
||||
if slug:
|
||||
row = conn.execute("SELECT * FROM sites WHERE slug=?", (slug,)).fetchone()
|
||||
elif host:
|
||||
row = conn.execute("SELECT * FROM sites WHERE custom_domain=?", (host.split(":")[0],)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _site_pages(conn, site_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.page_icon, p.cover_url, p.updated_at, sp.position
|
||||
FROM site_pages sp JOIN pages p ON p.id = sp.page_id
|
||||
WHERE sp.site_id=? AND (p.deleted_at IS NULL OR p.deleted_at='')
|
||||
ORDER BY sp.position, p.id""",
|
||||
(site_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["slug"] = _slugify(d.get("title") or "untitled") or f"page-{d['id']}"
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _find_site_page(pages: list[dict], ref: str) -> dict | None:
|
||||
ref = (ref or "").strip()
|
||||
if ref.isdigit():
|
||||
for p in pages:
|
||||
if p["id"] == int(ref):
|
||||
return p
|
||||
for p in pages:
|
||||
if p["slug"] == ref:
|
||||
return p
|
||||
# slug with -<id> suffix fallback
|
||||
m = re.search(r"-(\d+)$", ref)
|
||||
if m:
|
||||
for p in pages:
|
||||
if p["id"] == int(m.group(1)):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _render_page_html(page: dict) -> str:
|
||||
"""Render a pages row to HTML (blocks → public renderer, else <pre>)."""
|
||||
if page.get("content_format") == "blocks" and page.get("content"):
|
||||
try:
|
||||
from app.routers import dashboard as _dash
|
||||
blocks = json.loads(page["content"])
|
||||
try:
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
pass
|
||||
titles: dict = {}
|
||||
try:
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as _c:
|
||||
titles = token_labels(_c, page["content"])
|
||||
except Exception:
|
||||
titles = {}
|
||||
return _dash._render_blocks_public(blocks, titles)
|
||||
except Exception:
|
||||
return f"<p>{html.escape(str(page.get('content', '')))}</p>"
|
||||
if page.get("content"):
|
||||
return (
|
||||
"<pre style='white-space:pre-wrap;font-family:system-ui;"
|
||||
f"font-size:16px;line-height:1.6;'>{html.escape(page['content'])}</pre>"
|
||||
)
|
||||
return "<p style='color:#999'>Empty page.</p>"
|
||||
|
||||
|
||||
def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str,
|
||||
body_html: str, noindex: bool = False) -> str:
|
||||
nav = "".join(
|
||||
f"<a href='/s/{site['slug']}/{p['slug']}'"
|
||||
f" style='display:block;padding:6px 10px;border-radius:6px;text-decoration:none;"
|
||||
f"color:{'#fff' if p['id'] == current_id else '#bbb'};"
|
||||
f"background:{'#333' if p['id'] == current_id else 'transparent'}'>"
|
||||
f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}</a>"
|
||||
for p in pages
|
||||
)
|
||||
robots = "noindex,nofollow" if (noindex or site.get("noindex")) else "index,follow"
|
||||
desc = html.escape((site.get("title") or title)[:160])
|
||||
theme_bg = "#191919" if site.get("theme", "dark") == "dark" else "#ffffff"
|
||||
theme_fg = "#e0e0e0" if site.get("theme", "dark") == "dark" else "#222222"
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="robots" content="{robots}">
|
||||
<meta name="description" content="{desc}">
|
||||
<meta property="og:title" content="{html.escape(title)}">
|
||||
<meta property="og:description" content="{desc}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<title>{html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')}</title>
|
||||
<style>body{{font-family:system-ui,sans-serif;background:{theme_bg};color:{theme_fg};margin:0}}
|
||||
.layout{{display:flex;min-height:100vh}}.nav{{width:240px;padding:16px;border-right:1px solid #333}}
|
||||
.main{{flex:1;padding:32px;max-width:860px}}a{{color:#4c9aff}}
|
||||
@media(max-width:700px){{.nav{{display:none}}.main{{padding:16px}}}}</style></head>
|
||||
<body><div class="layout"><nav class="nav">
|
||||
<a href="/s/{site['slug']}" style="font-weight:700;color:{theme_fg};text-decoration:none">
|
||||
{html.escape(site.get('title') or 'Site')}</a><div style="height:12px"></div>{nav}</nav>
|
||||
<main class="main">{body_html}</main></div></body></html>"""
|
||||
|
||||
|
||||
def _track_view(site_id: int) -> None:
|
||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO site_views (site_id, day, views) VALUES (?, ?, 1)
|
||||
ON CONFLICT(site_id, day) DO UPDATE SET views=views+1""",
|
||||
(site_id, day),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _form_config(conn, collection_id: int) -> dict:
|
||||
row = conn.execute(
|
||||
"SELECT id, name, form_config_json FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
try:
|
||||
cfg = json.loads(row["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
cfg = {}
|
||||
return {"id": row["id"], "name": row["name"], "config": cfg}
|
||||
|
||||
|
||||
def _check_form_rate(ip: str) -> None:
|
||||
now = time.time()
|
||||
start, count = _form_rate.get(ip, (now, 0))
|
||||
if now - start > _FORM_RATE_WINDOW:
|
||||
_form_rate[ip] = (now, 1)
|
||||
return
|
||||
if count >= _FORM_RATE_MAX:
|
||||
raise HTTPException(429, "Too many submissions. Try again later.")
|
||||
_form_rate[ip] = (start, count + 1)
|
||||
|
||||
|
||||
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/sites")
|
||||
async def create_site(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
root_page_id = body.get("root_page_id")
|
||||
if not root_page_id:
|
||||
raise HTTPException(400, "root_page_id is required")
|
||||
slug = (body.get("slug") or "").strip().lower() or None
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Root page not found")
|
||||
if not slug:
|
||||
slug = _slugify(page["title"])
|
||||
base, i = slug, 1
|
||||
while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
slug = f"{base}-{i}"
|
||||
i += 1
|
||||
else:
|
||||
_check_slug(slug)
|
||||
if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
theme = body.get("theme", "dark")
|
||||
if theme not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
custom_domain = (body.get("custom_domain") or "").strip() or None
|
||||
if custom_domain and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=?", (custom_domain,)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
expires_at = body.get("expires_at")
|
||||
if expires_at:
|
||||
try:
|
||||
datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO sites (slug, root_page_id, title, theme, custom_domain,
|
||||
expires_at, noindex, analytics_id, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(slug, root_page_id, body.get("title") or page["title"],
|
||||
theme, custom_domain, expires_at,
|
||||
1 if body.get("noindex") else 0,
|
||||
(body.get("analytics_id") or "")[:120], user["id"]),
|
||||
)
|
||||
site_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)",
|
||||
(site_id, root_page_id),
|
||||
)
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.create", "site", site_id, f"slug={slug}", request)
|
||||
return JSONResponse(status_code=201, content=row_to_dict(site))
|
||||
|
||||
|
||||
@router.get("/api/v2/sites")
|
||||
async def list_sites(request: Request):
|
||||
user = _auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
resp = JSONResponse([row_to_dict(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}")
|
||||
async def get_site(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
pages = _site_pages(conn, site_id)
|
||||
out = row_to_dict(row)
|
||||
out["pages"] = pages
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/sites/{site_id}")
|
||||
async def update_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
site = dict(row)
|
||||
if site.get("created_by") != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
updates: dict = {}
|
||||
if "title" in body:
|
||||
updates["title"] = str(body["title"] or "")[:200]
|
||||
if "theme" in body:
|
||||
if body["theme"] not in ("light", "dark"):
|
||||
raise HTTPException(400, "theme must be light or dark")
|
||||
updates["theme"] = body["theme"]
|
||||
if "slug" in body and body["slug"] != site["slug"]:
|
||||
_check_slug(str(body["slug"]).lower())
|
||||
if conn.execute(
|
||||
"SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = str(body["slug"]).lower()
|
||||
if "custom_domain" in body:
|
||||
dom = (body["custom_domain"] or "").strip() or None
|
||||
if dom and conn.execute(
|
||||
"SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id)
|
||||
).fetchone():
|
||||
raise HTTPException(409, "Domain already linked to another site")
|
||||
updates["custom_domain"] = dom
|
||||
if "expires_at" in body:
|
||||
if body["expires_at"]:
|
||||
try:
|
||||
datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None
|
||||
updates["expires_at"] = body["expires_at"]
|
||||
if "noindex" in body:
|
||||
updates["noindex"] = 1 if body["noindex"] else 0
|
||||
if "analytics_id" in body:
|
||||
updates["analytics_id"] = str(body["analytics_id"] or "")[:120]
|
||||
if "password" in body:
|
||||
updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else ""
|
||||
if updates:
|
||||
updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id))
|
||||
conn.commit()
|
||||
site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
audit_log(user, "site.update", "site", site_id, ",".join(updates), request)
|
||||
return row_to_dict(site)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}")
|
||||
async def delete_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Site not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Site not found")
|
||||
conn.execute("DELETE FROM sites WHERE id=?", (site_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "site.delete", "site", site_id, "", request)
|
||||
return {"status": "deleted", "id": site_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/pages")
|
||||
async def list_site_pages(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
return {"site_id": site_id, "pages": _site_pages(conn, site_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/sites/{site_id}/pages")
|
||||
async def add_site_page(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)",
|
||||
(site_id, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
pages = _site_pages(conn, site_id)
|
||||
audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request)
|
||||
return {"site_id": site_id, "pages": pages}
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
|
||||
async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
if page_id == row["root_page_id"]:
|
||||
raise HTTPException(400, "Cannot remove the root page")
|
||||
conn.execute(
|
||||
"DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request)
|
||||
return {"status": "removed", "site_id": site_id, "page_id": page_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/stats")
|
||||
async def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
user = _auth_user(request)
|
||||
days = max(1, min(int(days or 30), 365))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row or (row["created_by"] != user["id"] and not user.get("is_admin")):
|
||||
raise HTTPException(404, "Site not found")
|
||||
rows = conn.execute(
|
||||
"SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?",
|
||||
(site_id, days),
|
||||
).fetchall()
|
||||
total = conn.execute(
|
||||
"SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,)
|
||||
).fetchone()[0]
|
||||
return {"site_id": site_id, "total_views": total,
|
||||
"days": [{"day": r["day"], "views": r["views"]} for r in rows]}
|
||||
|
||||
|
||||
# ── Public site rendering ──────────────────────────────────────────────────
|
||||
|
||||
def _public_guard(site: dict, request: Request):
|
||||
if _site_expired(site):
|
||||
return HTMLResponse("<h1>410 — Site expired.</h1>", status_code=410)
|
||||
if site.get("password_hash") and not _site_unlocked(request, site):
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<form method="post" action="/s/{site['slug']}/auth">
|
||||
<h2>🔒 {html.escape(site.get('title') or 'Protected site')}</h2>
|
||||
<input type="password" name="password" placeholder="Password"
|
||||
style="padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff">
|
||||
<button style="padding:8px 14px;border-radius:6px">Unlock</button></form></body></html>""",
|
||||
status_code=401,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/s/{slug}", response_class=HTMLResponse)
|
||||
async def public_site_home(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug,
|
||||
host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (site["root_page_id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Root page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
|
||||
async def site_sitemap(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site or _site_expired(site) or site.get("password_hash"):
|
||||
return PlainTextResponse("Not found", status_code=404)
|
||||
pages = _site_pages(conn, site["id"])
|
||||
base = str(request.base_url).rstrip("/")
|
||||
urls = [f"<url><loc>{base}/s/{slug}</loc></url>"] + [
|
||||
f"<url><loc>{base}/s/{slug}/{p['slug']}</loc></url>" for p in pages
|
||||
]
|
||||
return PlainTextResponse(
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>"
|
||||
f"{''.join(urls)}</urlset>",
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
|
||||
async def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
return HTMLResponse("<h1>404 — Site not found.</h1>", status_code=404)
|
||||
guard = _public_guard(site, request)
|
||||
if guard:
|
||||
return guard
|
||||
pages = _site_pages(conn, site["id"])
|
||||
target = _find_site_page(pages, page_ref)
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Page not in this site.</h1>", status_code=404)
|
||||
page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone()
|
||||
if not page:
|
||||
return HTMLResponse("<h1>404 — Page removed.</h1>", status_code=404)
|
||||
page = dict(page)
|
||||
_track_view(site["id"])
|
||||
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _render_page_html(page)
|
||||
return _site_shell(site=site, pages=pages, current_id=page["id"],
|
||||
title=page.get("title") or "Untitled", body_html=body)
|
||||
|
||||
|
||||
@router.post("/s/{slug}/auth")
|
||||
async def public_site_auth(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site:
|
||||
return JSONResponse({"detail": "Site not found"}, status_code=404)
|
||||
if not site.get("password_hash"):
|
||||
return {"status": "public"}
|
||||
ctype = request.headers.get("content-type", "")
|
||||
password = ""
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
password = (await request.json()).get("password", "")
|
||||
except Exception:
|
||||
password = ""
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
password = form.get("password", "")
|
||||
except Exception:
|
||||
password = ""
|
||||
if not verify_password(password or "", site["password_hash"] or ""):
|
||||
raise HTTPException(401, "Wrong password")
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth")
|
||||
resp = JSONResponse({"status": "unlocked"})
|
||||
resp.set_cookie(_site_auth_cookie(site["id"]), ser.dumps(site["id"]),
|
||||
httponly=True, samesite="lax", max_age=86400, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
# ── Public Forms ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/collections/{collection_id}/form")
|
||||
async def get_form_config(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
return {"collection_id": collection_id, "name": info["name"], "form": info["config"]}
|
||||
|
||||
|
||||
@router.put("/api/v2/collections/{collection_id}/form")
|
||||
async def put_form_config(collection_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
cfg = info["config"] if isinstance(info["config"], dict) else {}
|
||||
if "enabled" in body:
|
||||
cfg["enabled"] = bool(body["enabled"])
|
||||
for key in ("title", "success_message"):
|
||||
if key in body:
|
||||
cfg[key] = str(body[key] or "")[:300]
|
||||
for key in ("fields", "required", "notify_user_ids"):
|
||||
if key in body and isinstance(body[key], list):
|
||||
cfg[key] = body[key][:50]
|
||||
if "public_token" in body and body["public_token"]:
|
||||
tok = str(body["public_token"])
|
||||
if not _FORM_TOKEN_RE.match(tok):
|
||||
raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)")
|
||||
cfg["public_token"] = tok
|
||||
if cfg.get("enabled") and not cfg.get("public_token"):
|
||||
cfg["public_token"] = "f_" + secrets.token_urlsafe(9)
|
||||
conn.execute(
|
||||
"UPDATE collections SET form_config_json=? WHERE id=?",
|
||||
(json.dumps(cfg), collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
audit_log(user, "form.config", "collection", collection_id, "", request)
|
||||
return {"collection_id": collection_id, "form": cfg}
|
||||
|
||||
|
||||
def _collection_props(conn, collection_id: int) -> list[dict]:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
|
||||
|
||||
@router.get("/f/{token}", response_class=HTMLResponse)
|
||||
async def public_form(request: Request, token: str):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections").fetchone()
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
_ = row
|
||||
if not target:
|
||||
return HTMLResponse("<h1>404 — Form not found.</h1>", status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
fields = cfg.get("fields") or [p["name"] for p in props if p["prop_type"] != "formula"][:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
inputs = ""
|
||||
for name in fields:
|
||||
prop = next((p for p in props if p["name"] == name), None)
|
||||
ptype = (prop or {}).get("prop_type", "text")
|
||||
itype = {"number": "number", "email": "email", "url": "url",
|
||||
"date": "date", "phone": "tel"}.get(ptype, "text")
|
||||
req = "required" if name in required else ""
|
||||
if ptype in ("select", "status") and prop:
|
||||
try:
|
||||
opts = json.loads(prop.get("options_json") or "[]")
|
||||
except Exception:
|
||||
opts = []
|
||||
opts_html = "".join(
|
||||
f"<option>{html.escape(o.get('name', ''))}</option>" for o in opts)
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<select name='{html.escape(name)}' {req}>{opts_html}</select>")
|
||||
elif ptype == "checkbox":
|
||||
inputs += (f"<label><input type='checkbox' name='{html.escape(name)}'> "
|
||||
f"{html.escape(name)}</label>")
|
||||
else:
|
||||
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
|
||||
f"<input type='{itype}' name='{html.escape(name)}' {req}>")
|
||||
chrome = "" if embed else f"<h1>{html.escape(cfg.get('title') or coll['name'])}</h1>"
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{html.escape(cfg.get('title') or coll['name'])}</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#eee;margin:0;padding:24px}}
|
||||
form{{max-width:520px;margin:auto}}label{{display:block;margin:12px 0 4px}}
|
||||
input,select,textarea{{width:100%;padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff}}
|
||||
button{{margin-top:16px;padding:10px 18px;border-radius:6px;border:0;background:#2383E2;color:#fff}}</style>
|
||||
</head><body>{chrome}
|
||||
<form method="post" action="/f/{token}">
|
||||
<input type="text" name="__hp" style="display:none" tabindex="-1" autocomplete="off">
|
||||
{inputs}<button>Submit</button></form></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
@router.post("/f/{token}")
|
||||
async def submit_form(request: Request, token: str):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_form_rate(ip or "unknown")
|
||||
ctype = request.headers.get("content-type", "")
|
||||
data: dict = {}
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("__hp"):
|
||||
raise HTTPException(400, "Spam detected")
|
||||
with get_conn() as conn:
|
||||
target = None
|
||||
if _FORM_TOKEN_RE.match(token or ""):
|
||||
for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall():
|
||||
try:
|
||||
cfg = json.loads(c["form_config_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if cfg.get("enabled") and cfg.get("public_token") == token:
|
||||
target = (c, cfg)
|
||||
break
|
||||
if not target:
|
||||
# NOTE: return (not raise) — the global 404 handler redirects
|
||||
# non-/api paths to /workspaces, which would turn this into a 200.
|
||||
return JSONResponse({"detail": "Form not found"}, status_code=404)
|
||||
coll, cfg = target
|
||||
props = _collection_props(conn, coll["id"])
|
||||
by_name = {p["name"]: p for p in props}
|
||||
fields = cfg.get("fields") or list(by_name)[:10]
|
||||
required = set(cfg.get("required") or [])
|
||||
values: dict = {}
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
raw = data.get(name, "")
|
||||
if prop["prop_type"] == "checkbox":
|
||||
raw = True if raw in (True, "on", "true", "1", "checked") else False
|
||||
if name in required and (raw is None or raw == "" or raw is False):
|
||||
raise HTTPException(400, f"Field required: {name}")
|
||||
values[str(prop["id"])] = raw
|
||||
# Validate via property_types.validate_property_rule
|
||||
try:
|
||||
from app.services.property_types import validate_property_rule
|
||||
for name in fields:
|
||||
prop = by_name.get(name)
|
||||
if not prop:
|
||||
continue
|
||||
ok, _msg = validate_property_rule(
|
||||
prop.get("prop_type", "text"), values.get(str(prop["id"])),
|
||||
prop.get("validation_json") or prop.get("options_json") or "")
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Invalid value for {name}: {_msg}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages (collection_id, title, property_values_json)
|
||||
VALUES (?, ?, ?)""",
|
||||
(coll["id"], title or "Form response", json.dumps(values)),
|
||||
)
|
||||
row_id = cur.lastrowid
|
||||
ip_hash = hashlib.sha256(f"{ip}|{datetime.now(UTC).strftime('%Y-%m-%d')}".encode()).hexdigest()
|
||||
conn.execute(
|
||||
"INSERT INTO form_responses (collection_id, row_id, ip_hash) VALUES (?, ?, ?)",
|
||||
(coll["id"], row_id, ip_hash),
|
||||
)
|
||||
conn.commit()
|
||||
notify_ids = cfg.get("notify_user_ids") or []
|
||||
# Notify (never throws the submission)
|
||||
try:
|
||||
from app.services.notifications import create_notification
|
||||
for uid in notify_ids[:20]:
|
||||
try:
|
||||
create_notification(int(uid), None, "form_response",
|
||||
f"New response: {coll['name']}",
|
||||
f"{title}", "collection", coll["id"],
|
||||
f"/db/{coll['id']}")
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from app.services.automations import fire_event as _fire
|
||||
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
|
||||
except Exception:
|
||||
pass
|
||||
if "application/json" in ctype:
|
||||
return {"status": "ok", "row_id": row_id,
|
||||
"message": cfg.get("success_message") or "Merci !"}
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<p>{html.escape(cfg.get('success_message') or 'Merci !')}</p></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
# used by tests to reset the anonymous rate limiter
|
||||
def _reset_form_rate() -> None:
|
||||
_form_rate.clear()
|
||||
|
||||
|
||||
# Backwards-compat alias for tests importing ``get_bearer_user`` from here.
|
||||
__all__ = ["router", "get_bearer_user"]
|
||||
@@ -0,0 +1,226 @@
|
||||
"""FlowDeck — Passkeys / WebAuthn (v7.2.0).
|
||||
|
||||
Registration + passwordless login via the ``webauthn`` package (pinned in
|
||||
requirements). Challenges live in a short-lived in-memory store (5 min,
|
||||
single-process — same tradeoff as the SSE rooms). RP ID is derived from the
|
||||
request host. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["webauthn"], prefix="/auth/webauthn")
|
||||
|
||||
# key -> (challenge bytes, expires_at). key = f"reg:{user_id}" | f"login:{login}".
|
||||
_challenges: dict[str, tuple[bytes, float]] = {}
|
||||
_CHALLENGE_TTL = 300.0
|
||||
|
||||
|
||||
def _require_lib():
|
||||
try:
|
||||
import webauthn # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _store_challenge(key: str, challenge: bytes) -> None:
|
||||
_challenges[key] = (challenge, time.time() + _CHALLENGE_TTL)
|
||||
|
||||
|
||||
def _take_challenge(key: str) -> bytes | None:
|
||||
item = _challenges.pop(key, None)
|
||||
if not item:
|
||||
return None
|
||||
challenge, exp = item
|
||||
return challenge if exp > time.time() else None
|
||||
|
||||
|
||||
def _rp(request: Request) -> tuple[str, str]:
|
||||
host = (request.url.hostname or "localhost").split(":")[0]
|
||||
return host, f"{request.url.scheme}://{request.headers.get('host', host)}"
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/register/begin")
|
||||
async def register_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_registration_options, options_to_json
|
||||
user = _session_user(request)
|
||||
rp_id, _origin = _rp(request)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
exclude = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
|
||||
for r in existing]
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id, rp_name="FlowDeck", user_name=user.get("login", f"user{user['id']}"),
|
||||
user_id=str(user["id"]).encode(), exclude_credentials=exclude or None)
|
||||
_store_challenge(f"reg:{user['id']}", options.challenge)
|
||||
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/register/finish")
|
||||
async def register_finish(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_registration_response
|
||||
user = _session_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
challenge = _take_challenge(f"reg:{user['id']}")
|
||||
if not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
rp_id, origin = _rp(request)
|
||||
try:
|
||||
verified = verify_registration_response(
|
||||
credential=body.get("credential") or {},
|
||||
expected_challenge=challenge, expected_rp_id=rp_id, expected_origin=origin,
|
||||
require_user_verification=False)
|
||||
except Exception as exc: # noqa: BLE001 — invalid attestation → 400, never 500
|
||||
raise HTTPException(400, f"Registration rejected: {exc}") from None
|
||||
import base64
|
||||
cred_id = base64.urlsafe_b64encode(verified.credential_id).decode().rstrip("=")
|
||||
pubkey = base64.b64encode(bytes(verified.credential_public_key)).decode()
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO webauthn_credentials
|
||||
(user_id, credential_id, public_key, sign_count, name)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(user["id"], cred_id, pubkey, verified.sign_count,
|
||||
str(body.get("name") or "Passkey")[:80]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(409, "Credential already registered") from None
|
||||
kid = cur.lastrowid
|
||||
return {"id": kid, "status": "registered"}
|
||||
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def login_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_authentication_options, options_to_json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
if not login:
|
||||
raise HTTPException(400, "login required")
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
creds = conn.execute("SELECT credential_id FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
if not creds:
|
||||
raise HTTPException(400, "No passkeys for this account")
|
||||
rp_id, _origin = _rp(request)
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
allow = [PublicKeyCredentialDescriptor(id=_b64url_to_bytes(r["credential_id"]))
|
||||
for r in creds]
|
||||
options = generate_authentication_options(rp_id=rp_id, allow_credentials=allow)
|
||||
_store_challenge(f"login:{login}", options.challenge)
|
||||
return JSONResponse(content=__import__("json").loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/login/finish")
|
||||
async def login_finish(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_authentication_response
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
challenge = _take_challenge(f"login:{login}")
|
||||
if not login or not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
stored = conn.execute("SELECT * FROM webauthn_credentials WHERE user_id=?",
|
||||
(user["id"],)).fetchall()
|
||||
rp_id, origin = _rp(request)
|
||||
credential = body.get("credential") or {}
|
||||
cred_id = (credential.get("id") or "").rstrip("=")
|
||||
match = next((dict(r) for r in stored if r["credential_id"].rstrip("=") == cred_id), None)
|
||||
if not match:
|
||||
raise HTTPException(401, "Unknown credential")
|
||||
import base64
|
||||
try:
|
||||
verified = verify_authentication_response(
|
||||
credential=credential, expected_challenge=challenge,
|
||||
expected_origin=origin, expected_rp_id=rp_id,
|
||||
credential_public_key=base64.b64decode(match["public_key"]),
|
||||
credential_current_sign_count=match["sign_count"],
|
||||
require_user_verification=False)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(401, f"Authentication rejected: {exc}") from None
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE webauthn_credentials SET sign_count=? WHERE id=?",
|
||||
(verified.new_sign_count, match["id"]))
|
||||
conn.execute("UPDATE users SET last_login=? WHERE id=?",
|
||||
(str(time.time()), user["id"]))
|
||||
conn.commit()
|
||||
ud = dict(conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone())
|
||||
session = SessionManager.create_session(ud, request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_keys(request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
|
||||
" WHERE user_id=? ORDER BY id", (user["id"],)).fetchall()
|
||||
return {"keys": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_key(key_id: int, request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
||||
(key_id, user["id"]))
|
||||
conn.commit()
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Key not found")
|
||||
return {"status": "deleted", "id": key_id}
|
||||
|
||||
|
||||
def _b64url_to_bytes(data: str) -> bytes:
|
||||
import base64
|
||||
padded = data + "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def reset_challenges() -> None:
|
||||
_challenges.clear()
|
||||
|
||||
|
||||
__all__ = ["router", "reset_challenges", "secrets"]
|
||||
@@ -0,0 +1,537 @@
|
||||
"""FlowDeck — teamspaces, verified pages, wiki home, collab polish (v7.3.0).
|
||||
|
||||
Routes under ``/api/v2/wiki`` plus the guest entry point ``/g/{token}``.
|
||||
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import wiki
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
from app.services.notifications import create_notification
|
||||
|
||||
router = APIRouter(tags=["wiki"])
|
||||
|
||||
|
||||
def _esc(value) -> str:
|
||||
return html.escape(str(value))
|
||||
|
||||
|
||||
def _user(request: Request) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not sess or not sess.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin, is_active FROM users WHERE id=?",
|
||||
(sess["id"],)).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
raise HTTPException(403, "Account disabled")
|
||||
return sess
|
||||
|
||||
|
||||
def _workspace_id(request: Request) -> int:
|
||||
wid = request.query_params.get("workspace_id")
|
||||
if not wid:
|
||||
raise HTTPException(400, "workspace_id required")
|
||||
try:
|
||||
wid = int(wid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "invalid workspace_id") from None
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM workspaces WHERE id=?", (wid,)).fetchone():
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
return wid
|
||||
|
||||
|
||||
def _teamspace_or_404(teamspace_id: int, user_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM teamspaces WHERE id=?", (teamspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Teamspace not found")
|
||||
if not wiki.can_read_teamspace(user_id, teamspace_id):
|
||||
# private teamspace → 404 (not 403), same as restricted collections
|
||||
raise HTTPException(404, "Teamspace not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _page_or_404(page_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, workspace_id, teamspace_id, deleted_at FROM pages WHERE id=?",
|
||||
(page_id,)).fetchone()
|
||||
if not row or row["deleted_at"]:
|
||||
raise HTTPException(404, "Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _is_admin(user: dict) -> bool:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
return bool(row and row["is_admin"])
|
||||
|
||||
|
||||
def _can_verify(user: dict, page: dict) -> bool:
|
||||
"""Admin, or an editor/owner of the teamspace / workspace holding the page."""
|
||||
if _is_admin(user):
|
||||
return True
|
||||
if page.get("teamspace_id"):
|
||||
return wiki.can_write_teamspace(user["id"], page["teamspace_id"])
|
||||
wid = page.get("workspace_id")
|
||||
if not wid:
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
if owner and owner["owner_id"] == user["id"]:
|
||||
return True
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(wid, user["id"])).fetchone()
|
||||
return bool(member and member["role"] in ("owner", "admin", "editor"))
|
||||
|
||||
|
||||
# ── teamspaces ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces")
|
||||
async def list_teamspaces(request: Request):
|
||||
user = _user(request)
|
||||
wid = request.query_params.get("workspace_id")
|
||||
if wid:
|
||||
try:
|
||||
wid = int(wid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "invalid workspace_id") from None
|
||||
else:
|
||||
wid = None
|
||||
return {"teamspaces": wiki.list_teamspaces(user["id"], wid)}
|
||||
|
||||
|
||||
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
|
||||
async def teamspace_page(teamspace_id: int, request: Request):
|
||||
"""Teamspace detail HTML page — sidebar entry point."""
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT name FROM workspaces WHERE id=?",
|
||||
(ts["workspace_id"],)).fetchone()
|
||||
pages = wiki.teamspace_pages(teamspace_id)
|
||||
collections = wiki.teamspace_collections(teamspace_id)
|
||||
page_rows = "\n".join(
|
||||
f'<a class="ts-row" href="/pages/{p["id"]}" style="display:flex;align-items:center;gap:8px;'
|
||||
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
|
||||
f'<span>📄</span><span>{_esc(p["title"] or "Untitled")}</span></a>'
|
||||
for p in pages)
|
||||
coll_rows = "\n".join(
|
||||
f'<a class="ts-row" href="/db/{c["id"]}" style="display:flex;align-items:center;gap:8px;'
|
||||
f'padding:8px 10px;border-radius:6px;color:inherit;text-decoration:none;">'
|
||||
f'<span>{_esc(c["icon"] or "🗄️")}</span><span>{_esc(c["name"] or "Untitled")}</span></a>'
|
||||
for c in collections)
|
||||
content_html = f"""
|
||||
<div style="max-width:860px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:26px;display:flex;align-items:center;gap:10px;">
|
||||
{_esc(ts['name'])}{' <span style="font-size:13px;padding:2px 8px;border-radius:10px;background:rgba(76,154,255,.15);color:#4c9aff;">🔒 private</span>' if ts['private'] else ''}
|
||||
</h1>
|
||||
<p style="color:var(--text-dim);">{_esc(ts.get('description') or '')}</p>
|
||||
<div style="display:flex;gap:10px;font-size:12px;color:var(--text-dim);margin-bottom:24px;flex-wrap:wrap;">
|
||||
<span>Workspace: {_esc((ws["name"]) if ws else '')}</span>
|
||||
<span>·</span><span>Role: {_esc(ts['role'])}</span>
|
||||
<span>·</span><span>{len(pages) + len(collections)} items</span>
|
||||
</div>
|
||||
<h2 style="font-size:16px;margin:20px 0 8px;">Pages</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
{page_rows or '<p style="color:var(--text-dim);font-size:13px;">No pages yet.</p>'}
|
||||
</div>
|
||||
<h2 style="font-size:16px;margin:24px 0 8px;">Databases</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
{coll_rows or '<p style="color:var(--text-dim);font-size:13px;">No databases yet.</p>'}
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.ts-row:hover{{background:var(--bg-hover);}}
|
||||
</style>"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
return block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
page_title=ts["name"],
|
||||
title_prefix="Teamspace",
|
||||
page_icon="🏛️",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/teamspaces")
|
||||
async def create_teamspace(request: Request):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name or len(name) > 120:
|
||||
raise HTTPException(400, "name required (max 120 chars)")
|
||||
wid = int(body.get("workspace_id") or 0)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(wid, user["id"])).fetchone()
|
||||
allowed = (ws["owner_id"] == user["id"] or (admin and admin["is_admin"])
|
||||
or (member and member["role"] in ("admin", "editor", "owner")))
|
||||
if not allowed:
|
||||
raise HTTPException(403, "Editor role required in the workspace")
|
||||
try:
|
||||
tsid = wiki.create_teamspace(wid, name, user["id"],
|
||||
description=body.get("description") or "",
|
||||
private=bool(body.get("private")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from None
|
||||
audit_log(user, "teamspace.create", "teamspace", tsid, name, request)
|
||||
return JSONResponse(status_code=201, content={"id": tsid, "name": name})
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
|
||||
async def get_teamspace(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
ts["member_count"] = len(wiki.teamspace_member_ids(teamspace_id))
|
||||
return ts
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
|
||||
async def list_members(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT m.user_id, m.role, u.login, u.full_name FROM teamspace_members m
|
||||
JOIN users u ON u.id = m.user_id WHERE m.teamspace_id=? ORDER BY u.login""",
|
||||
(teamspace_id,)).fetchall()
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
role = body.get("role")
|
||||
if role not in wiki.TEAMSPACE_ROLES:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM users WHERE id=?", (member_id,)).fetchone():
|
||||
raise HTTPException(404, "User not found")
|
||||
conn.execute(
|
||||
"""INSERT INTO teamspace_members (teamspace_id, user_id, role) VALUES (?,?,?)
|
||||
ON CONFLICT(teamspace_id, user_id) DO UPDATE SET role=excluded.role""",
|
||||
(teamspace_id, member_id, role))
|
||||
conn.commit()
|
||||
audit_log(user, "teamspace.member.set", "teamspace", teamspace_id,
|
||||
f"u{member_id}={role}", request)
|
||||
return {"status": "ok", "user_id": member_id, "role": role}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
|
||||
(teamspace_id, member_id))
|
||||
conn.commit()
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Not a member")
|
||||
return {"status": "removed", "user_id": member_id}
|
||||
|
||||
|
||||
# ── verified pages ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/verification")
|
||||
async def get_verification(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
return {"verification": wiki.verification(page_id)}
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def verify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if not _can_verify(user, page):
|
||||
raise HTTPException(403, "Editor role required to verify a page")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = wiki.verify_page(page_id, user["id"],
|
||||
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
|
||||
note=body.get("note") or "")
|
||||
audit_log(user, "page.verify", "page", page_id, out.get("expires_at") or "", request)
|
||||
return {"verification": out}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def unverify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
if not wiki.unverify_page(page_id):
|
||||
raise HTTPException(404, "Page is not verified")
|
||||
audit_log(user, "page.unverify", "page", page_id, "", request)
|
||||
return {"status": "unverified", "page_id": page_id}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/verified")
|
||||
async def list_verified(request: Request):
|
||||
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.page_icon, p.teamspace_id,
|
||||
v.verified_at, v.expires_at, v.note, u.login
|
||||
FROM page_verifications v
|
||||
JOIN pages p ON p.id = v.page_id
|
||||
LEFT JOIN users u ON u.id = v.verified_by
|
||||
WHERE p.workspace_id=? AND p.deleted_at IS NULL""",
|
||||
(wid,)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
item = dict(r)
|
||||
if wiki.is_expired(r):
|
||||
continue # badge lapsed → not listed
|
||||
if item["teamspace_id"] and not wiki.can_read_teamspace(user["id"],
|
||||
item["teamspace_id"]):
|
||||
continue # private teamspace → hidden
|
||||
item["active"] = True
|
||||
out.append(item)
|
||||
return {"pages": out}
|
||||
|
||||
|
||||
# ── follows ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/follow")
|
||||
async def follow_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
now = wiki.toggle_follow(page_id, user["id"])
|
||||
return {"page_id": page_id, "following": now}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/followers")
|
||||
async def list_followers(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
ids = wiki.followers(page_id)
|
||||
if not ids:
|
||||
return {"followers": []}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, login, full_name FROM users WHERE id IN ({','.join('?' * len(ids))})",
|
||||
ids).fetchall()
|
||||
return {"followers": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── comment reactions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def react(comment_id: int, request: Request):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
if not emoji:
|
||||
raise HTTPException(400, "emoji required")
|
||||
try:
|
||||
counts = wiki.toggle_reaction(comment_id, user["id"], emoji)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Comment not found") from None
|
||||
return {"comment_id": comment_id, "reactions": counts}
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def list_reactions(comment_id: int, request: Request):
|
||||
_user(request)
|
||||
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
|
||||
|
||||
|
||||
# ── guest shares ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def create_guest(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to share")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
share = wiki.create_guest_share(page_id, body.get("email") or "",
|
||||
body.get("role") or "viewer",
|
||||
user["id"], days=body.get("days", 30))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "page.guest_share", "page", page_id, share["email"], request)
|
||||
return JSONResponse(status_code=201, content={
|
||||
"id": share["id"], "token": share["token"], "role": share["role"],
|
||||
"expires_at": share["expires_at"], "url": f"/g/{share['token']}"})
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def list_guests(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, email, role, expires_at, revoked, created_at FROM guest_shares"
|
||||
" WHERE page_id=? ORDER BY id DESC", (page_id,)).fetchall()
|
||||
return {"guests": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/guests/{share_id}")
|
||||
async def revoke_guest(share_id: int, request: Request):
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
|
||||
raise HTTPException(404, "Guest share not found")
|
||||
conn.execute("UPDATE guest_shares SET revoked=1 WHERE id=?", (share_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "page.guest_revoke", "guest_share", share_id, "", request)
|
||||
return {"status": "revoked", "id": share_id}
|
||||
|
||||
|
||||
_GUEST_404 = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Link unavailable — FlowDeck</title>
|
||||
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:520px;
|
||||
margin:80px auto;padding:0 20px;color:#1f2328;text-align:center}
|
||||
h1{font-size:20px} p{color:#656d76;line-height:1.6}</style></head><body>
|
||||
<h1>This link is unavailable</h1>
|
||||
<p>It may have expired, been revoked, or never existed.<br>
|
||||
Ask the person who shared it with you for a new link.</p></body></html>"""
|
||||
|
||||
|
||||
@router.get("/g/{token}", response_class=HTMLResponse)
|
||||
async def guest_page(token: str, request: Request):
|
||||
"""Account-less page access (read-only or commenter). 404 if inactive."""
|
||||
share = wiki.resolve_guest_share(token)
|
||||
if not share:
|
||||
return HTMLResponse(_GUEST_404, status_code=404)
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title, content, created_at, updated_at, deleted_at"
|
||||
" FROM pages WHERE id=?", (share["page_id"],)).fetchone()
|
||||
if not page or page["deleted_at"]:
|
||||
return HTMLResponse(_GUEST_404, status_code=404)
|
||||
wiki.record_view(share["page_id"])
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{page['title']} — FlowDeck guest</title>
|
||||
<style>body{{font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;
|
||||
margin:40px auto;padding:0 20px;line-height:1.6;color:#1f2328}}
|
||||
.guest-banner{{background:#fff4e5;border:1px solid #ffd8a8;padding:10px 14px;
|
||||
border-radius:8px;margin-bottom:24px;font-size:14px}}
|
||||
pre{{background:#f6f8fa;padding:14px;border-radius:8px;overflow:auto;
|
||||
white-space:pre-wrap;word-break:break-word}}</style></head><body>
|
||||
<div class="guest-banner">You are viewing this page as a guest
|
||||
({share['role']}{' — expires ' + str(share['expires_at']) if share['expires_at'] else ''}).
|
||||
Editing is disabled.</div>
|
||||
<h1>{page['title']}</h1><pre>{page['content'] or ''}</pre></body></html>"""
|
||||
|
||||
|
||||
# ── page views ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/views")
|
||||
async def page_views(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
raise HTTPException(403, "Editor role required to read analytics")
|
||||
return wiki.view_stats(page_id, days=request.query_params.get("days", 30))
|
||||
|
||||
|
||||
# ── wiki home ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/home")
|
||||
async def wiki_home(request: Request):
|
||||
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
recents = conn.execute(
|
||||
"""SELECT id, title, page_icon, updated_at FROM pages
|
||||
WHERE workspace_id=? AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 20""", (wid,)).fetchall()
|
||||
verified = conn.execute(
|
||||
"""SELECT v.page_id, v.verified_at, v.expires_at FROM page_verifications v
|
||||
JOIN pages p ON p.id = v.page_id
|
||||
WHERE p.workspace_id=? AND p.deleted_at IS NULL
|
||||
AND (v.expires_at IS NULL OR v.expires_at > ?)""",
|
||||
(wid, __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc).replace(microsecond=0).isoformat()),
|
||||
).fetchall()
|
||||
return {"workspace_id": wid,
|
||||
"teamspaces": wiki.list_teamspaces(user["id"], wid),
|
||||
"verified": [dict(r) for r in verified],
|
||||
"recents": [dict(r) for r in recents]}
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/verify-expiry-sweep")
|
||||
async def sweep_expiry(request: Request):
|
||||
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()[0]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
sent = 0
|
||||
for row in wiki.expiring_verifications(days=7):
|
||||
create_notification(row["owner_id"], None, "page.verification_expiring",
|
||||
"Verification expiring soon",
|
||||
f"“{row['title']}” loses its verified badge on {row['expires_at']}.",
|
||||
resource_type="page", resource_id=row["page_id"])
|
||||
sent += 1
|
||||
return {"notified": sent}
|
||||
|
||||
|
||||
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/blocks/preview")
|
||||
async def preview_blocks(request: Request):
|
||||
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
|
||||
_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
blocks = body.get("blocks")
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(400, "blocks must be a list")
|
||||
if len(blocks) > 200:
|
||||
raise HTTPException(400, "max 200 blocks per preview")
|
||||
from app.services.wiki_blocks import mmdc_available, render_block
|
||||
out = [{"type": b.get("type"), "html": render_block(b)} for b in blocks
|
||||
if isinstance(b, dict) and b.get("type") in ("mermaid", "equation_inline", "progress")]
|
||||
return {"rendered": out, "mmdc_available": mmdc_available()}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import workers as worker_service
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
resolve_bearer_token,
|
||||
row_to_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["workers"])
|
||||
|
||||
|
||||
def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if sess:
|
||||
return sess
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired API token")
|
||||
if require_write and not has_scope(user.get("_token_scopes") or "read", "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
def _row_to_api(row) -> dict:
|
||||
d = row_to_dict(row)
|
||||
d.pop("code_py", None) # code only via ?include_code=1 or owner fetch
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/api/v2/workers")
|
||||
async def create_worker(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Untitled worker").strip()[:200]
|
||||
code = body.get("code_py") or ""
|
||||
try:
|
||||
worker_service.validate_code(code)
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
slug = worker_service.unique_slug(body.get("slug") or name)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO workers (slug, workspace_id, name, code_py, schedule_cron,
|
||||
shared, daily_budget_s, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(slug, body.get("workspace_id"), name, code,
|
||||
(body.get("schedule_cron") or "")[:60],
|
||||
1 if body.get("shared") else 0,
|
||||
max(1, min(int(body.get("daily_budget_s") or 60), 3600)),
|
||||
user["id"]))
|
||||
conn.commit()
|
||||
wid = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "worker.create", "worker", wid, slug, request)
|
||||
return JSONResponse(status_code=201, content={**_row_to_api(row), "code_py": code})
|
||||
|
||||
|
||||
@router.get("/api/v2/workers")
|
||||
async def list_workers(request: Request):
|
||||
_auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM workers").fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM workers ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset)).fetchall()
|
||||
resp = JSONResponse([_row_to_api(r) for r in rows])
|
||||
for k, v in paginate_headers(total).items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}")
|
||||
async def get_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
out = _row_to_api(row)
|
||||
if (request.query_params.get("include_code") == "1" or row["created_by"] == user["id"]
|
||||
or user.get("is_admin")):
|
||||
out["code_py"] = row["code_py"]
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/workers/{worker_id}")
|
||||
async def update_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can update this worker")
|
||||
updates: dict = {}
|
||||
if "name" in body:
|
||||
updates["name"] = str(body["name"] or "")[:200]
|
||||
if "code_py" in body:
|
||||
try:
|
||||
worker_service.validate_code(body["code_py"] or "")
|
||||
except worker_service.WorkerRejected as exc:
|
||||
raise HTTPException(400, f"code rejected: {exc}") from None
|
||||
updates["code_py"] = body["code_py"] or ""
|
||||
if "schedule_cron" in body:
|
||||
updates["schedule_cron"] = str(body["schedule_cron"] or "")[:60]
|
||||
if "shared" in body:
|
||||
updates["shared"] = 1 if body["shared"] else 0
|
||||
if "daily_budget_s" in body:
|
||||
updates["daily_budget_s"] = max(1, min(int(body["daily_budget_s"] or 60), 3600))
|
||||
if "slug" in body and body["slug"] != row["slug"]:
|
||||
if not worker_service._SLUG_RE.match(str(body["slug"] or "")):
|
||||
raise HTTPException(400, "Invalid slug")
|
||||
if conn.execute("SELECT id FROM workers WHERE slug=? AND id!=?",
|
||||
(body["slug"], worker_id)).fetchone():
|
||||
raise HTTPException(409, "Slug already taken")
|
||||
updates["slug"] = body["slug"]
|
||||
if updates:
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE workers SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), worker_id))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
audit_log(user, "worker.update", "worker", worker_id, ",".join(updates), request)
|
||||
return _row_to_api(row)
|
||||
|
||||
|
||||
@router.delete("/api/v2/workers/{worker_id}")
|
||||
async def delete_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if row["created_by"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only the owner can delete this worker")
|
||||
conn.execute("DELETE FROM workers WHERE id=?", (worker_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "worker.delete", "worker", worker_id, "", request)
|
||||
return {"status": "deleted", "id": worker_id}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/run")
|
||||
async def run_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Worker not found")
|
||||
if (row["created_by"] != user["id"] and not row["shared"]
|
||||
and not user.get("is_admin")):
|
||||
raise HTTPException(403, "Worker is private")
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
out = await loop.run_in_executor(
|
||||
None, worker_service.run_worker, worker_id, body.get("ctx") or {})
|
||||
except HTTPException:
|
||||
raise
|
||||
audit_log(user, "worker.run", "worker", worker_id, out.get("status", ""), request)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}/runs")
|
||||
async def worker_runs(worker_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
limit, _offset = parse_pagination(request, default_limit=20)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM workers WHERE id=?", (worker_id,)).fetchone():
|
||||
raise HTTPException(404, "Worker not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM worker_runs WHERE worker_id=? ORDER BY id DESC LIMIT ?",
|
||||
(worker_id, limit)).fetchall()
|
||||
return {"worker_id": worker_id, "runs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/fork")
|
||||
async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
out = worker_service.fork_worker(worker_id, user["id"])
|
||||
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
||||
return JSONResponse(status_code=201, content=out)
|
||||
|
||||
|
||||
@router.get("/api/v2/workers-usage")
|
||||
async def workers_usage(request: Request):
|
||||
user = _auth_user(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
return {"workspace_id": wid,
|
||||
"used_seconds_today": round(worker_service.daily_usage_s(wid), 2),
|
||||
"user_id": user.get("id")}
|
||||
Reference in New Issue
Block a user