feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
FlowDeck CI / lint (push) Successful in 1m48s
FlowDeck CI / test (push) Failing after 21m19s
FlowDeck CI / docker (push) Skipped

- 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:
2026-09-30 20:02:57 -04:00
parent d074689b18
commit 1706ad1ee9
260 changed files with 10667 additions and 353788 deletions
+47 -5
View File
@@ -40,19 +40,28 @@ from app.routers import (
)
from app.routers.api_v2 import router as api_v2_router
from app.routers.api_v2_agent import router as api_v2_agent_router
from app.routers.audit import router as audit_router
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router
from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router
from app.routers.meetings import router as meetings_router
from app.routers.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router
from app.routers.realtime import router as realtime_router
from app.routers.scim import router as scim_router
from app.routers.search_ai import router as search_ai_router
from app.routers.sites import router as sites_router
from app.routers.sso import router as sso_router
from app.routers.web_clipper import api_router as web_clipper_api_router
from app.routers.web_clipper import router as web_clipper_router
from app.routers.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_router
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -100,6 +109,14 @@ async def lifespan(_app: FastAPI):
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
# ── Semantic search (v6.9.0): incremental vector indexing ──
from app.services.semantic_search import semantic_index_scheduler
semantic_task = asyncio.create_task(semantic_index_scheduler())
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
from app.services.calendar_sync import calendar_sync_scheduler
calendar_task = asyncio.create_task(calendar_sync_scheduler())
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
from app.services.webhook_outbound import webhook_retry_scheduler
webhook_task = None
@@ -110,7 +127,7 @@ async def lifespan(_app: FastAPI):
try:
yield
finally:
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
if webhook_task is not None:
_tasks = _tasks + (webhook_task,)
for task in _tasks:
@@ -124,7 +141,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.7.0",
version="7.3.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -173,6 +190,17 @@ app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router)
app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.include_router(sites_router)
app.include_router(search_ai_router)
app.include_router(workers_router)
app.include_router(meetings_router)
# v7.2.0 — enterprise admin
app.include_router(scim_router)
app.include_router(webauthn_router)
app.include_router(audit_router)
app.include_router(governance_router)
# v7.3.0 — teamspaces + verified wiki
app.include_router(wiki_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -262,17 +290,31 @@ async def http_exception_handler(request: Request, exc: _StarHTTPException):
"""
status = getattr(exc, "status_code", 500)
detail = getattr(exc, "detail", str(exc))
is_api_v2 = request.url.path.startswith("/api/v2")
# Programmatic API prefixes that must always answer JSON errors instead of
# being redirected to the HTML shell (SCIM 2.0 clients, WebAuthn fetch).
JSON_ERROR_PREFIXES = ("/api/v2", "/scim/v2", "/auth/webauthn")
is_json_api = request.url.path.startswith(JSON_ERROR_PREFIXES)
if status == 404:
if request.url.path.startswith("/api/v2"):
if is_api_v2:
from app.services.api_v2_helpers import problem_response
return problem_response(request, exc)
if "/api" in request.url.path:
if is_json_api and request.url.path.startswith("/scim/v2"):
from fastapi.responses import JSONResponse
return JSONResponse(
{"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": detail if isinstance(detail, str) else "Not found",
"status": "404"},
status_code=404,
headers={"Content-Type": "application/scim+json"},
)
if "/api" in request.url.path or is_json_api:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
from fastapi.responses import RedirectResponse
return RedirectResponse("/workspaces", status_code=302)
# Non-404: RFC7807 for /api/v2
if request.url.path.startswith("/api/v2"):
if is_api_v2:
from app.services.api_v2_helpers import problem_response
return problem_response(request, exc)
from fastapi.responses import JSONResponse
+3 -1
View File
@@ -16,7 +16,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/saml", "/auth/oidc", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding", "/s/", "/f/"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+453
View File
@@ -985,6 +985,459 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
)
@register(24, "v6.8.0: Sites & public Forms")
def _migration_sites_forms(conn: sqlite3.Connection) -> None:
"""v6.8.0 — Notion Sites + Forms publics (voir docs/V68_Sites_Forms.md).
``sites`` — mini-site multi-pages (slug, root_page, thème,
domaine custom, password hash, expiry, noindex).
``site_pages`` — arbre public ordonné (site_id, page_id, position).
``site_views`` — compteur de vues jour/site (upsert, pas d'IP brute).
``form_responses`` — log des soumissions anonymes (ip_hash jour, pas d'IP).
``collections.form_config_json`` — config du formulaire public par DB.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'dark',
custom_domain TEXT UNIQUE,
password_hash TEXT DEFAULT '',
expires_at TIMESTAMP,
noindex INTEGER NOT NULL DEFAULT 0,
analytics_id TEXT DEFAULT '',
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS site_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE(site_id, page_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_site_pages_site ON site_pages(site_id, position)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS site_views (
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
day TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (site_id, day)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS form_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
row_id INTEGER REFERENCES collection_pages(id) ON DELETE SET NULL,
ip_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
if "form_config_json" not in cols:
conn.execute(
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
)
@register(25, "v6.9.0: semantic search + Ask AI")
def _migration_semantic_search(conn: sqlite3.Connection) -> None:
"""v6.9.0 — hybrid lexical+vector search and RAG Ask AI (docs/V69_* md).
``semantic_embeddings`` — hashed-TF chunk vectors (no external dep):
keyed by (resource_type, resource_id, chunk_id) so both ``page``
and ``collection`` resources are indexed. (Design doc names a
``page_embeddings`` table; the generic key covers collections too.)
``semantic_index_state`` — last indexed timestamp per resource for the
incremental background job.
``pages.search_excluded`` — opt-out flag respected by indexer + search.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS semantic_embeddings (
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
chunk_id INTEGER NOT NULL,
chunk_text TEXT NOT NULL DEFAULT '',
embedding BLOB NOT NULL,
model TEXT NOT NULL DEFAULT 'hash-256',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_type, resource_id, chunk_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sem_emb_res "
"ON semantic_embeddings(resource_type, resource_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS semantic_index_state (
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_type, resource_id)
)
"""
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "search_excluded" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
)
@register(26, "v7.0.0: automations v2 (steps) + workers")
def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
"""v7.0.0 — multi-step automations + sandboxed workers (docs/V70_* md).
``automation_steps`` — ordered trigger/condition/delay/action chain per
automation. Legacy single trigger+actions columns keep working
(engine falls back when an automation has no steps).
``automations.trigger_mode`` — ``any`` (default) or ``all`` (every
trigger event must arrive within a 5-minute window).
``workers`` / ``worker_runs`` — custom Python snippets (cron/manual),
shareable across the team, with execution logs + daily budget.
``collection_properties.button_automation_id`` — native DB button cells.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS automation_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asteps_auto "
"ON automation_steps(automation_id, position)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
code_py TEXT NOT NULL DEFAULT '',
schedule_cron TEXT DEFAULT '',
shared INTEGER NOT NULL DEFAULT 0,
daily_budget_s INTEGER NOT NULL DEFAULT 60,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS worker_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
status TEXT NOT NULL,
logs TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
"ON worker_runs(worker_id, created_at)"
)
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
if "trigger_mode" not in auto_cols:
conn.execute(
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
)
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "button_automation_id" not in prop_cols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
"INTEGER REFERENCES automations(id) ON DELETE SET NULL"
)
@register(27, "v7.1.0: calendar sync + meeting transcripts")
def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
"""v7.1.0 — external calendar sync + AI meeting notes (docs/V71_* md).
``calendar_links`` — per-user link between a collection and an external
calendar (google REST / generic caldav), tokens Fernet-encrypted.
``meeting_transcripts`` — uploaded audio + transcript + AI summary per page.
``collection_pages.external_event_id`` — remote event id for push/pull
matching and conflict detection.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS calendar_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
tokens_enc TEXT NOT NULL DEFAULT '',
calendar_id TEXT NOT NULL DEFAULT 'primary',
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
date_property TEXT DEFAULT '',
sync_token TEXT DEFAULT '',
last_sync TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, provider, calendar_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS meeting_transcripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
audio_path TEXT NOT NULL DEFAULT '',
transcript TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'fr',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
"ON meeting_transcripts(page_id)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
if "external_event_id" not in cols:
conn.execute(
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_cp_external "
"ON collection_pages(collection_id, external_event_id)"
)
@register(28, "v7.2.0: SCIM + 2FA + audit UI + agent governance")
def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
"""v7.2.0 — enterprise admin (docs/V72_* md).
``scim_tokens`` — Bearer tokens for SCIM provisioning (admin-managed).
``domain_claims`` — DNS/well-known verified domains + SSO enforcement.
``webauthn_credentials`` — passkeys (credential_id, COSE public key).
``agent_policies`` — per-workspace tool scope + approval gate.
``agent_approvals`` — approval queue for gated write actions.
``users.totp_secret_enc`` / ``totp_backup_hashes`` — TOTP 2FA.
(``users.is_active`` already exists — used by SCIM suspend.)
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS scim_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id),
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS domain_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
txt_token TEXT NOT NULL DEFAULT '',
verified INTEGER NOT NULL DEFAULT 0,
auto_join_role TEXT NOT NULL DEFAULT 'viewer',
enforce_sso INTEGER NOT NULL DEFAULT 0,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS agent_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
allowed_tools_json TEXT,
max_steps INTEGER NOT NULL DEFAULT 12,
require_approval INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS agent_approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL DEFAULT 0,
tool TEXT NOT NULL DEFAULT '',
args_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
requester_id INTEGER REFERENCES users(id),
approver_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
"ON agent_approvals(status, created_at)"
)
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
if "totp_secret_enc" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
if "totp_backup_hashes" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_backup_hashes TEXT DEFAULT '[]'")
@register(29, "v7.3.0: teamspaces + verified pages + collab polish")
def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
"""v7.3.0 — teamspaces, verified pages, collab polish (docs/V73_*.md).
``teamspaces`` / ``teamspace_members`` — namespaces for pages + databases;
``private=1`` hides a teamspace from non-members (404, like restricted
collections). ``page_verifications`` — ✅ badge with expiry.
``comment_reactions`` / ``page_follows`` — collab polish.
``guest_shares`` — account-less page access via ``/g/<token>``.
``page_views`` — daily counters, same pattern as ``site_views``.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS teamspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT DEFAULT '',
private INTEGER NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, name)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS teamspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
teamspace_id INTEGER NOT NULL REFERENCES teamspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'editor',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(teamspace_id, user_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_teamspace_members_user "
"ON teamspace_members(user_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_verifications (
page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
verified_by INTEGER REFERENCES users(id),
note TEXT NOT NULL DEFAULT '',
verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_page_verifications_expiry "
"ON page_verifications(expires_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS comment_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(comment_id, user_id, emoji)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_follows (
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_id, user_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS guest_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
email TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'viewer',
created_by INTEGER REFERENCES users(id),
expires_at TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_views (
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
day TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (page_id, day)
)
"""
)
for table in ("pages", "collections"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "teamspace_id" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
@register(23, "v6.7.0: SSO/SAML enterprise auth")
def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
"""v6.7.0 — SSO/SAML 2.0 + OIDC enterprise authentication.
+124
View File
@@ -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})
+101
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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>
""")
+98
View File
@@ -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
+206
View File
@@ -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
+297
View File
@@ -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}
+95
View File
@@ -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")}
+4 -3
View File
@@ -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},
}
+833
View File
@@ -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"]
+226
View File
@@ -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"]
+537
View File
@@ -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()}
+216
View File
@@ -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")}
+29 -3
View File
@@ -18,9 +18,10 @@ import re
from app.config import settings
from app.db import get_conn
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
from app.services.permission_manager import WRITE_TOOLS, PermissionManager
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
@@ -185,7 +186,10 @@ class AgentEngine:
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try:
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
# v7.2.0 — the workspace policy may cap iterations below the global max.
policy_max = get_policy(self.workspace_id).get("max_steps") or MAX_ITERATIONS
iterations = min(settings.agent_max_iterations or MAX_ITERATIONS, policy_max)
for _step in range(iterations):
if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"})
break
@@ -235,8 +239,30 @@ class AgentEngine:
tool, args = call["name"], call.get("arguments") or {}
call_id = tool_specs[idx]["id"]
denied = False
# v7.2.0 — workspace tool scope + human approval gate, checked
# *before* permissions so a scoped-out tool never reaches ACLs.
gov = check_tool(self.user_id, self.workspace_id, tool,
is_write=tool in WRITE_TOOLS,
conversation_id=conversation_id)
if not gov.get("allowed"):
detail = gov.get("reason") or "Refusé par la politique agent"
if gov.get("approval_id"):
detail = (f"Approbation requise (demande #{gov['approval_id']}) "
f"— action suspendue")
yield self._event("action", {
"tool": tool, "status": "approval_required" if gov.get("approval_id")
else "error", "detail": detail,
"approval_id": gov.get("approval_id")})
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": detail},
ensure_ascii=False),
})
denied = True
try:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
if not denied:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
except Exception as exc: # permission / approval guard
detail = self._exc_detail(exc)
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
+89
View File
@@ -0,0 +1,89 @@
"""FlowDeck — agent governance (v7.2.0): workspace tool scope + approval gate.
``agent_policies``: ``allowed_tools_json`` (null = all tools), ``max_steps``,
``require_approval`` (write tools pause for a human). ``check_tool()`` is
consulted by ``AgentEngine`` before ``PermissionManager.assert_can``.
``agent.run.approval_requested`` is emitted on the outbound webhook bus so
external systems can subscribe. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import json
from app.db import get_conn
def get_policy(workspace_id: int | None) -> dict:
"""Effective policy (workspace row, else global row, else defaults)."""
with get_conn() as conn:
row = None
if workspace_id is not None:
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id=?",
(workspace_id,)).fetchone()
if row is None:
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS NULL"
).fetchone()
if not row:
return {"allowed_tools": None, "max_steps": 12, "require_approval": False}
d = dict(row)
try:
allowed = json.loads(d.get("allowed_tools_json")) if d.get("allowed_tools_json") else None
except (TypeError, ValueError):
allowed = None
return {"allowed_tools": allowed, "max_steps": d.get("max_steps") or 12,
"require_approval": bool(d.get("require_approval"))}
def check_tool(user_id: int, workspace_id: int | None, tool: str,
is_write: bool, conversation_id: int = 0) -> dict:
"""Policy gate for one tool call.
Returns {allowed: bool, approval_id: int|None}. Denied tools and gated
writes (pending approval) return allowed=False; the engine renders both
as action errors without executing.
"""
from app.services.permission_manager import WRITE_TOOLS
policy = get_policy(workspace_id)
allowed = policy["allowed_tools"]
if allowed is not None and tool not in set(allowed):
return {"allowed": False, "approval_id": None, "reason": "tool not in policy scope"}
if is_write or tool in WRITE_TOOLS:
if policy["require_approval"]:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO agent_approvals
(conversation_id, tool, args_json, status, requester_id)
VALUES (?,?,?,?,?)""",
(conversation_id, tool, "{}", "pending", user_id))
conn.commit()
approval_id = cur.lastrowid
try:
import asyncio
from app.services.webhook_outbound import fire_event as _fire
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
loop.create_task(_fire("agent.run.approval_requested", {
"approval_id": approval_id, "tool": tool,
"conversation_id": conversation_id}))
except Exception: # noqa: BLE001 — webhook never blocks policy
pass
return {"allowed": False, "approval_id": approval_id,
"reason": "approval requested"}
return {"allowed": True, "approval_id": None, "reason": ""}
def decide_approval(approval_id: int, approver_id: int, approve: bool) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(approval_id,)).fetchone()
if not row or row["status"] != "pending":
return None
conn.execute("UPDATE agent_approvals SET status=?, approver_id=? WHERE id=?",
("approved" if approve else "rejected", approver_id, approval_id))
conn.commit()
return dict(conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(approval_id,)).fetchone())
+414
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime, timedelta
import httpx
@@ -246,6 +247,43 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
)
return f"notified user {user_id}"
if atype == "slack":
url = _secret_value(action.get("webhook_url") or action.get("url") or "")
if not url:
raise ValueError("slack action requires a webhook_url")
_, text = _maybe_convert_prediction(
action.get("text") or action.get("message") or "Automation fired", context)
return await _post_slack(url, text)
if atype == "email":
to = action.get("to", "")
_, subject = _maybe_convert_prediction(action.get("subject", "FlowDeck automation"), context)
_, body = _maybe_convert_prediction(action.get("body", action.get("message", "")), context)
return await _send_email_action(to, subject, body, context)
if atype == "forge_issue":
provider = (action.get("provider") or "gitea").lower()
owner = action.get("owner", "")
repo = action.get("repo", "")
if not owner or not repo:
raise ValueError("forge_issue requires owner + repo")
_, title = _maybe_convert_prediction(action.get("title", "Automation issue"), context)
_, body = _maybe_convert_prediction(action.get("body", ""), context)
return await _create_forge_issue(
provider, owner, repo, title, body,
labels=action.get("labels") or [],
user_id=context.get("created_by"),
)
if atype == "agent_trigger":
agent_id = action.get("agent_id")
if not agent_id:
raise ValueError("agent_trigger requires an agent_id")
_, message = _maybe_convert_prediction(action.get("message", ""), context)
return await _run_linked_agent(
int(agent_id), context.get("created_by") or 1,
context.get("workspace_id"), message, context)
raise ValueError(f"unknown action type: {atype!r}")
@@ -260,6 +298,11 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
if not auto["enabled"]:
return {"status": "skipped", "detail": "automation disabled"}
# v7.0.0: chained steps take over when present (legacy path otherwise).
stepped = await _maybe_run_stepped(auto, trigger_source, context)
if stepped is not None:
return stepped
props = context.get("properties")
before = context.get("before_properties")
if not evaluate_conditions(auto["condition_json"], props, before):
@@ -307,6 +350,15 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled).
if event == "page.updated":
try:
from app.services.wiki import notify_followers_of_page_update
notify_followers_of_page_update(
payload.get("page_id"), payload.get("actor_id"),
payload.get("title") or "")
except Exception: # noqa: BLE001 — notifications are best-effort
logger.debug("followers notification failed for page.updated")
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
try:
from app.services.webhook_outbound import fire_event as fire_webhooks
@@ -320,14 +372,29 @@ async def fire_event(event: str, payload: dict):
WHERE trigger_type='event' AND event=? AND enabled=1""",
(event,),
).fetchall()
stepped_ids: set[int] = set()
try:
with get_conn() as _c:
stepped_ids = {r[0] for r in _c.execute(
"SELECT DISTINCT automation_id FROM automation_steps").fetchall()}
except Exception: # noqa: BLE001 — table missing on very old DBs
pass
for row in rows:
auto = dict(row)
if auto["id"] in stepped_ids:
continue # v7.0.0: handled by fire_stepped_event below (no double run)
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
continue
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# v7.0.0: step-based automations (multi-trigger any/all, chains).
try:
await fire_stepped_event(event, payload)
except Exception: # noqa: BLE001
logger.debug("stepped dispatch failed for %s", event)
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
@@ -409,6 +476,353 @@ async def automation_scheduler():
await run_automation(auto["id"], "cron", context)
except Exception: # noqa: BLE001
logger.warning("Cron automation %s errored", auto["id"])
# v7.0.0: workers on a cron schedule share the same 60s loop.
try:
from app.services.workers import run_due_workers
await run_due_workers()
except Exception: # noqa: BLE001
logger.debug("worker cron iteration failed")
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60)
# ═══════════ v7.0.0 — multi-step automations (triggers/conditions/delay) ══
STEP_KINDS = ("trigger", "condition", "delay", "action")
STEP_ACTION_TYPES = ("webhook", "set_property", "create_page", "notify",
"slack", "email", "forge_issue", "agent_trigger")
ALL_MODE_WINDOW_S = 300.0
# mode=all bookkeeping (single-process): automation_id -> {event: timestamp}.
_ALL_PENDING: dict[int, dict[str, float]] = {}
def reset_all_pending() -> None:
"""Test helper: clear the mode=all arrival window."""
_ALL_PENDING.clear()
def _secret_value(stored: str | None) -> str:
"""Decrypt a Fernet secret, falling back to raw plaintext (legacy/tests)."""
if not stored:
return ""
try:
from app.services.sso_provisioning import decrypt_secret
decrypted = decrypt_secret(stored)
if decrypted:
return decrypted
except Exception: # noqa: BLE001
pass
if isinstance(stored, str) and not stored.startswith("gAAAAA"):
return stored
return ""
def validate_step(kind: str, config: dict) -> None:
"""Validate a step payload. Raises ValueError with a human message."""
from fastapi import HTTPException
if kind not in STEP_KINDS:
raise HTTPException(400, f"invalid kind: {kind!r} (want trigger|condition|delay|action)")
config = config or {}
if kind == "trigger":
if not config.get("event"):
raise HTTPException(400, "trigger step requires an event")
elif kind == "condition":
if config.get("op", "eq") not in COND_OPS:
raise HTTPException(400, f"invalid op: {config.get('op')!r}")
elif kind == "delay":
try:
seconds = int(config.get("seconds", 0))
except (TypeError, ValueError):
raise HTTPException(400, "delay step requires integer seconds") from None
if seconds < 0 or seconds > 86400:
raise HTTPException(400, "delay seconds must be 0..86400")
elif kind == "action":
if config.get("type") not in STEP_ACTION_TYPES:
raise HTTPException(400, f"invalid action type: {config.get('type')!r}")
def get_steps(automation_id: int) -> list[dict]:
"""Ordered steps of an automation (empty when legacy single-mode)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automation_steps WHERE automation_id=? ORDER BY position, id",
(automation_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
try:
d["config"] = json.loads(d.get("config_json") or "{}")
except (TypeError, json.JSONDecodeError):
d["config"] = {}
out.append(d)
return out
def _steps_by_kind(steps: list[dict]) -> dict[str, list[dict]]:
grouped: dict[str, list[dict]] = {"trigger": [], "condition": [],
"delay": [], "action": []}
for s in steps:
if s.get("kind") in grouped:
grouped[s["kind"]].append(s)
return grouped
def _step_trigger_matches(step_cfg: dict, event: str, payload: dict,
automation_collection_id: int | None) -> bool:
if step_cfg.get("event") != event:
return False
want_coll = step_cfg.get("collection_id") or automation_collection_id
if want_coll and payload.get("collection_id") != want_coll:
return False
return True
async def _run_with_steps(auto: dict, steps: list[dict], trigger_source: str,
context: dict) -> dict:
"""Execute a chained automation. Records one run row with per-step detail."""
grouped = _steps_by_kind(steps)
props = context.get("properties")
before = context.get("before_properties")
# Legacy single condition still applies on top of step conditions.
if not evaluate_conditions(auto.get("condition_json") or "[]", props, before):
_save_run(auto["id"], trigger_source, "skipped", "condition not met",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "condition not met"}
for cond in grouped["condition"]:
cfg = cond.get("config") or {}
if not match_condition_props(props, before, {
"property": cfg.get("property"), "op": cfg.get("op", "eq"),
"value": cfg.get("value")}):
_save_run(auto["id"], trigger_source, "skipped",
f"step condition not met: {cfg.get('property')}",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "step condition not met"}
ctx = dict(context)
ctx["automation_name"] = auto["name"]
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
ordered = sorted(steps, key=lambda s: (s.get("position", 0), s.get("id", 0)))
results = []
try:
for step in ordered:
kind = step.get("kind")
cfg = step.get("config") or {}
if kind in ("trigger", "condition"):
continue
if kind == "delay":
seconds = max(0, min(int(cfg.get("seconds", 0)), 300))
if seconds:
await asyncio.sleep(seconds)
results.append(f"delay {cfg.get('seconds', 0)}s")
elif kind == "action":
summary = await _run_action({"type": cfg.get("type"), **cfg}, ctx,
trigger_source)
results.append(summary)
detail = "; ".join(results) or "no steps executed"
_save_run(auto["id"], trigger_source, "fired", detail,
ctx.get("collection_id"), ctx.get("page_id"))
try:
from app.services.webhook_outbound import fire_event as _fire_wh
await _fire_wh("automation.fired", {
"automation_id": auto["id"], "name": auto["name"],
"trigger": trigger_source, "collection_id": ctx.get("collection_id"),
"page_id": ctx.get("page_id"), "detail": detail})
except Exception: # noqa: BLE001
logger.debug("automation.fired webhook dispatch failed")
return {"status": "fired", "detail": detail}
except Exception as exc: # noqa: BLE001
logger.warning("Automation %s (steps) failed: %s", auto["id"], exc)
_save_run(auto["id"], trigger_source, "error", str(exc),
ctx.get("collection_id"), ctx.get("page_id"))
return {"status": "error", "detail": str(exc)}
async def _maybe_run_stepped(auto: dict, trigger_source: str, context: dict) -> dict | None:
"""Run via steps when the automation has any; None → use legacy path."""
steps = get_steps(auto["id"])
if not steps:
return None
return await _run_with_steps(auto, steps, trigger_source, context)
def _match_stepped_automations(event: str, payload: dict) -> list[tuple[dict, list[dict]]]:
"""Automations (enabled) whose trigger steps match ``event`` + collection."""
with get_conn() as conn:
rows = conn.execute(
"""SELECT a.* FROM automations a
JOIN automation_steps s ON s.automation_id = a.id
WHERE a.enabled=1 AND s.kind='trigger' GROUP BY a.id"""
).fetchall()
matched = []
for row in rows:
auto = dict(row)
steps = get_steps(auto["id"])
triggers = [s for s in steps if s.get("kind") == "trigger"]
if any(_step_trigger_matches(t.get("config") or {}, event, payload,
auto.get("collection_id")) for t in triggers):
matched.append((auto, triggers))
return matched
async def fire_stepped_event(event: str, payload: dict) -> None:
"""Dispatch ``event`` to step-based automations (mode any/all).
Called from :func:`fire_event` after the legacy matcher. Unknown events
(not in the webhook catalogue) still work here — steps are independent
from outbound webhooks.
"""
now = time.time()
for auto, triggers in _match_stepped_automations(event, payload):
# Skip automations already handled by the legacy matcher to avoid
# double runs (legacy = trigger_type event + no steps).
if not get_steps(auto["id"]):
continue
mode = (auto.get("trigger_mode") or "any").lower()
if mode == "all":
pending = _ALL_PENDING.setdefault(auto["id"], {})
pending[event] = now
# Expire arrivals outside the window.
for ev in [e for e, ts in pending.items() if now - ts > ALL_MODE_WINDOW_S]:
del pending[ev]
wanted = {t.get("config", {}).get("event") for t in triggers}
if not wanted <= set(pending):
continue
_ALL_PENDING.pop(auto["id"], None)
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
async def _post_slack(webhook_url: str, text: str) -> str:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json={"text": text})
if resp.status_code >= 400:
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
return f"slack → ({resp.status_code})"
async def _send_email_action(to: str, subject: str, body: str, context: dict) -> str:
from app.services import mailer
address = (to or "").strip()
if address.startswith("user:"):
try:
uid = int(address.split(":", 1)[1])
except ValueError:
raise ValueError(f"bad email target: {to!r}") from None
with get_conn() as conn:
row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone()
address = (row["email"] if row and row["email"] else "")
if not address:
raise ValueError(f"user {uid} has no email")
if not address:
address = None
with get_conn() as conn:
row = conn.execute("SELECT email FROM users WHERE id=?",
(context.get("created_by") or 1,)).fetchone()
if row and row["email"]:
address = row["email"]
if not address:
return "email skipped (no recipient)"
ok = mailer.send_email(address, subject or "FlowDeck automation", body or "")
return f"email → {address}" if ok else "email skipped (SMTP not configured)"
async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
body: str, labels: list | None = None,
user_id: int | None = None) -> str:
token = ""
if user_id:
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=?",
(user_id, provider)).fetchone()
token = (row["access_token"] if row else "") or ""
if provider == "github":
if not token:
raise ValueError("github action needs a linked GitHub account (token)")
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/issues",
headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"},
json={"title": title, "body": body,
"labels": labels or []} if labels else {"title": title, "body": body},
)
if resp.status_code >= 400:
raise RuntimeError(f"github returned HTTP {resp.status_code}")
return f"github issue #{resp.json().get('number')} in {owner}/{repo}"
# gitea (default)
from app.services.gitea_client import GiteaClient
gitea = GiteaClient(user_token=token or None)
issue = await gitea.create_issue(owner, repo, title, body)
return f"gitea issue #{issue.get('number')} in {owner}/{repo}"
async def _run_linked_agent(agent_id: int, user_id: int, workspace_id: int | None,
message: str, context: dict) -> str:
from app.services.agent_engine import AgentEngine
with get_conn() as conn:
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not agent:
raise ValueError(f"agent {agent_id} not found")
cur = conn.execute(
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
VALUES (?,?,?,?)""",
(agent_id, user_id,
f"Automation: {context.get('automation_name', 'run')}",
json.dumps({"workspace_id": workspace_id})),
)
conv_id = cur.lastrowid
conn.commit()
objective = ((agent["system_instructions"] or "").strip()
or f"Exécute l'agent « {agent['name']} ».")
if message:
objective = f"{objective}\n\n{message}"
engine = AgentEngine(user_id, workspace_id, agent["model"] or None)
final = ""
async for _ev in engine.run(conv_id, objective, model=agent["model"]):
pass
with get_conn() as conn:
row = conn.execute(
"SELECT content FROM agent_messages WHERE conversation_id=? AND role='assistant'"
" ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
final = (row["content"][:300] if row and row["content"] else "")
return f"agent « {agent['name']} » ran (conversation {conv_id})" + (f": {final}" if final else "")
# ── v7.0.0 native DB button ────────────────────────────────────────────────
async def press_button(collection_id: int, row_id: int, prop_ref: str | int,
user_id: int) -> dict:
"""Run the automation linked to a ``button`` property cell."""
with get_conn() as conn:
if isinstance(prop_ref, int) or str(prop_ref).isdigit():
prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=? AND collection_id=?",
(int(prop_ref), collection_id)).fetchone()
else:
prop = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? AND name=?",
(collection_id, prop_ref)).fetchone()
if not prop:
raise ValueError("button property not found")
prop = dict(prop)
if prop.get("prop_type") != "button":
raise ValueError("property is not a button")
auto_id = prop.get("button_automation_id")
if not auto_id:
raise ValueError("button has no linked automation")
row = conn.execute(
"SELECT id FROM collection_pages WHERE id=? AND collection_id=?",
(row_id, collection_id)).fetchone()
if not row:
raise ValueError("row not found")
context = get_page_context(row_id, collection_id)
context["created_by"] = user_id
return await run_automation(auto_id, "button", context)
+528
View File
@@ -0,0 +1,528 @@
"""FlowDeck — external calendar sync (v7.1.0).
Bidirectional sync between a collection (date property) and an external
calendar: Google Calendar (REST) or any CalDAV server (raw REPORT/PUT, no
extra dependency). Tokens are Fernet-encrypted at rest.
Matching: ``collection_pages.external_event_id`` ↔ remote event id.
Conflicts (both sides changed since ``last_sync``): last-write-wins +
in-app ``calendar.conflict`` notification (manual edit resolves).
See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from datetime import UTC, datetime, timedelta
import httpx
from app.db import get_conn
logger = logging.getLogger(__name__)
PROVIDERS = ("google", "caldav")
SYNC_LOOKBACK_DAYS = 30
SYNC_LOOKAHEAD_DAYS = 90
class SyncError(RuntimeError):
"""Raised when the remote calendar cannot be reached/authorized."""
# ── links ──────────────────────────────────────────────────────────────────
def _encrypt_tokens(creds: dict) -> str:
from app.services.sso_provisioning import encrypt_secret
return encrypt_secret(json.dumps(creds or {}))
def _decrypt_tokens(tokens_enc: str) -> dict:
if not tokens_enc:
return {}
try:
from app.services.sso_provisioning import decrypt_secret
raw = decrypt_secret(tokens_enc)
if raw:
return json.loads(raw)
except Exception: # noqa: BLE001
pass
try: # legacy plaintext (tests)
data = json.loads(tokens_enc)
return data if isinstance(data, dict) else {}
except Exception: # noqa: BLE001
return {}
def save_link(user_id: int, provider: str, collection_id: int,
credentials: dict, calendar_id: str = "primary",
date_property: str = "") -> dict:
if provider not in PROVIDERS:
raise ValueError(f"provider must be google|caldav, got {provider!r}")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collections WHERE id=?",
(collection_id,)).fetchone():
raise ValueError("collection not found")
cur = conn.execute(
"""INSERT INTO calendar_links
(user_id, provider, tokens_enc, calendar_id, collection_id, date_property)
VALUES (?,?,?,?,?,?)
ON CONFLICT(user_id, provider, calendar_id) DO UPDATE SET
tokens_enc=excluded.tokens_enc, collection_id=excluded.collection_id,
date_property=excluded.date_property""",
(user_id, provider, _encrypt_tokens(credentials),
calendar_id or "primary", collection_id, date_property or ""))
conn.commit()
row = conn.execute(
"SELECT * FROM calendar_links WHERE user_id=? AND provider=? AND calendar_id=?",
(user_id, provider, calendar_id or "primary")).fetchone()
_ = cur
out = dict(row)
out.pop("tokens_enc", None)
return out
def list_links(user_id: int) -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, user_id, provider, calendar_id, collection_id,"
" date_property, last_sync, created_at FROM calendar_links WHERE user_id=?"
" ORDER BY id", (user_id,)).fetchall()
return [dict(r) for r in rows]
def delete_link(user_id: int, link_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM calendar_links WHERE id=? AND user_id=?",
(link_id, user_id))
conn.commit()
return cur.rowcount > 0
def _load_link(link_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM calendar_links WHERE id=?", (link_id,)).fetchone()
return dict(row) if row else None
# ── remote I/O (module-level = monkeypatchable) ────────────────────────────
def _remote_event(eid: str, title: str, start: str, description: str = "",
updated: str = "") -> dict:
return {"id": str(eid), "title": title or "Untitled", "start": start,
"description": description or "", "updated": updated or ""}
async def google_list_events(tokens: dict, calendar_id: str,
time_min: str, time_max: str) -> list[dict]:
access = tokens.get("access_token", "")
if not access:
raise SyncError("google link has no access_token — relink the calendar")
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
f"/events?singleEvents=true&orderBy=startTime"
f"&timeMin={time_min}&timeMax={time_max}")
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar")
if resp.status_code >= 400:
raise SyncError(f"google returned HTTP {resp.status_code}")
out = []
for item in resp.json().get("items", []):
start = (item.get("start") or {}).get("dateTime") or (item.get("start") or {}).get("date") or ""
out.append(_remote_event(item.get("id", ""), item.get("summary", ""),
start, item.get("description", ""),
item.get("updated", "")))
return out
async def google_push_event(tokens: dict, calendar_id: str, event: dict,
remote_id: str = "") -> str:
access = tokens.get("access_token", "")
if not access:
raise SyncError("google link has no access_token — relink the calendar")
body = {"summary": event.get("title", ""),
"description": event.get("description", ""),
"start": {"date": event.get("start", "")[:10]},
"end": {"date": event.get("start", "")[:10]}}
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
async with httpx.AsyncClient(timeout=15) as client:
if remote_id:
resp = await client.patch(f"{base}/{remote_id}",
headers={"Authorization": f"Bearer {access}"}, json=body)
else:
resp = await client.post(base, headers={"Authorization": f"Bearer {access}"},
json=body)
if resp.status_code == 401:
raise SyncError("google token expired — relink the calendar")
if resp.status_code >= 400:
raise SyncError(f"google returned HTTP {resp.status_code}")
return str(resp.json().get("id", remote_id or ""))
_CALDAV_REPORT = """<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop><D:getetag/><C:calendar-data/></D:prop>
<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter></C:comp-filter></C:filter>
</C:calendar-query>"""
def _parse_caldav_events(xml_text: str) -> list[dict]:
"""Minimal multistatus → event parser (UID/SUMMARY/DTSTART/DESCRIPTION)."""
import re
import xml.etree.ElementTree as ET
events = []
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return []
ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"}
for resp in root.findall("D:response", ns):
href = resp.findtext("D:href", default="", namespaces=ns)
data_el = resp.find(".//{urn:ietf:params:xml:ns:caldav}calendar-data")
if data_el is None or not data_el.text:
continue
ics = data_el.text
uid = re.search(r"^UID:(.+)$", ics, re.M)
summary = re.search(r"^SUMMARY:(.+)$", ics, re.M)
dtstart = re.search(r"^DTSTART(?:;[^:]*)?:(.+)$", ics, re.M)
desc = re.search(r"^DESCRIPTION:(.+)$", ics, re.M)
events.append(_remote_event(
(uid.group(1).strip() if uid else href.strip("/").split("/")[-1]),
summary.group(1).strip() if summary else "Untitled",
_ics_to_date(dtstart.group(1).strip()) if dtstart else "",
desc.group(1).strip() if desc else ""))
return events
def _ics_to_date(value: str) -> str:
value = value.strip()
if len(value) >= 8 and value[:8].isdigit():
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
return value[:10]
def _event_to_ics(uid: str, title: str, date: str, description: str = "") -> str:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
day = (date or "")[:10].replace("-", "")
return (f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//FlowDeck//Sync//EN\r\n"
f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{stamp}\r\nDTSTART;VALUE=DATE:{day}\r\n"
f"SUMMARY:{title}\r\nDESCRIPTION:{description}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[dict]:
url = creds.get("url", "")
if not url:
raise SyncError("caldav link needs a calendar url")
auth = (creds.get("username", ""), creds.get("password", ""))
body = _CALDAV_REPORT.format(
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.request("REPORT", url, content=body,
headers={"Depth": "1",
"Content-Type": "application/xml"})
if resp.status_code == 401:
raise SyncError("caldav rejected credentials")
if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}")
return _parse_caldav_events(resp.text)
async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> str:
url = (creds.get("url", "") or "").rstrip("/")
if not url:
raise SyncError("caldav link needs a calendar url")
auth = (creds.get("username", ""), creds.get("password", ""))
uid = remote_id or f"flowdeck-{uuid.uuid4().hex}@flowdeck"
href = f"{url}/{uid}.ics" if not remote_id else (
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
event.get("start", ""), event.get("description", ""))
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
if resp.status_code >= 400:
raise SyncError(f"caldav returned HTTP {resp.status_code}")
return uid
# ── mapping + sync ─────────────────────────────────────────────────────────
def _date_prop_id(conn, collection_id: int, wanted: str = "") -> tuple[str, str] | None:
props = conn.execute(
"SELECT id, name FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,)).fetchall()
if wanted:
for p in props:
if str(p["id"]) == str(wanted) or p["name"] == wanted:
return str(p["id"]), p["name"]
return None
for p in props:
# prop_type lives in the row; fetch full rows only when needed
full = conn.execute("SELECT prop_type FROM collection_properties WHERE id=?",
(p["id"],)).fetchone()
if full and full["prop_type"] == "date":
return str(p["id"]), p["name"]
return None
def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
raw = values.get(prop_id, values.get(prop_name, ""))
if isinstance(raw, dict):
raw = raw.get("date") or raw.get("value") or ""
return str(raw or "")
def _to_epoch(value: str | None) -> float:
if not value:
return 0.0
text = str(value).strip()
try:
if text.endswith("Z"):
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
else:
dt = datetime.fromisoformat(text[:19] if "T" in text else text[:19])
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp()
except Exception: # noqa: BLE001
try:
return time.mktime(time.strptime(text[:10], "%Y-%m-%d"))
except Exception: # noqa: BLE001
return 0.0
def _window() -> tuple[str, str]:
now = datetime.now(UTC)
start = (now - timedelta(days=SYNC_LOOKBACK_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
end = (now + timedelta(days=SYNC_LOOKAHEAD_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
return start, end
async def sync_link(link_id: int) -> dict:
"""One bidirectional sync pass. Returns {pulled, pushed, conflicts}."""
link = _load_link(link_id)
if not link:
raise ValueError("link not found")
creds = _decrypt_tokens(link.get("tokens_enc") or "")
collection_id = link.get("collection_id")
if not collection_id:
raise ValueError("link has no collection")
with get_conn() as conn:
date_prop = _date_prop_id(conn, collection_id, link.get("date_property") or "")
if not date_prop:
raise ValueError("collection has no date property")
prop_id, prop_name = date_prop
tmin, tmax = _window()
if link["provider"] == "google":
remote = await google_list_events(creds, link.get("calendar_id") or "primary",
tmin, tmax)
else:
remote = await caldav_list_events(creds, tmin, tmax)
last_sync = _to_epoch(link.get("last_sync"))
pulled = pushed = conflicts = 0
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, property_values_json, updated_at,"
" COALESCE(external_event_id, '') AS xid FROM collection_pages"
" WHERE collection_id=?", (collection_id,)).fetchall()
local = {r["xid"]: dict(r) for r in rows if r["xid"]}
seen_remote: set[str] = set()
touched: set[int] = set() # rows written by this pull pass — never push back
for ev in remote:
eid = ev.get("id", "")
if not eid:
continue
seen_remote.add(eid)
day = (ev.get("start") or "")[:10]
if eid not in local:
values: dict = {}
values[prop_id] = day
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages"
" WHERE collection_id=?", (collection_id,)).fetchone()[0]
conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, position, property_values_json, external_event_id)
VALUES (?,?,?,?,?)""",
(collection_id, ev.get("title") or "Untitled", max_pos,
json.dumps(values), eid))
pulled += 1
continue
row = local[eid]
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
local_day = _row_date(values, prop_id, prop_name)[:10]
remote_newer = _to_epoch(ev.get("updated")) > _to_epoch(row["updated_at"])
local_dirty = _to_epoch(row["updated_at"]) > last_sync and local_day != day
if remote_newer and local_dirty and local_day and day and local_day != day:
# Conflict: both sides moved → last-write-wins + notify.
if _to_epoch(ev.get("updated")) >= _to_epoch(row["updated_at"]):
values[prop_id] = day
conn.execute(
"UPDATE collection_pages SET property_values_json=?,"
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), row["id"]))
touched.add(row["id"])
conflicts += 1
_notify_conflict(conn, link, row, ev)
elif day and day != local_day:
values[prop_id] = day
conn.execute(
"UPDATE collection_pages SET property_values_json=?,"
" updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), row["id"]))
touched.add(row["id"])
pulled += 1
# Push local changes (created locally or edited after last_sync).
for xid, row in local.items():
if row["id"] in touched:
continue
if xid in seen_remote:
# Edited locally since last sync and remote unchanged → push.
if last_sync and _to_epoch(row["updated_at"]) > last_sync:
await _push(link, creds, row, prop_id, prop_name, xid)
pushed += 1
continue
# Remote deleted the event → drop the local id (keep the row).
conn.execute("UPDATE collection_pages SET external_event_id='' WHERE id=?",
(row["id"],))
# Rows never linked and recently touched → create remotely.
fresh = conn.execute(
"SELECT id, title, property_values_json, updated_at FROM collection_pages"
" WHERE collection_id=? AND COALESCE(external_event_id, '')=''",
(collection_id,)).fetchall()
for row in fresh:
try:
values = json.loads(row["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
day = _row_date(values, prop_id, prop_name)[:10]
if not day:
continue
new_id = await _push(link, creds, dict(row), prop_id, prop_name, "")
conn.execute("UPDATE collection_pages SET external_event_id=? WHERE id=?",
(new_id, row["id"]))
pushed += 1
conn.execute("UPDATE calendar_links SET last_sync=CURRENT_TIMESTAMP WHERE id=?",
(link_id,))
conn.commit()
return {"pulled": pulled, "pushed": pushed, "conflicts": conflicts}
async def _push(link: dict, creds: dict, row: dict, prop_id: str,
prop_name: str, remote_id: str) -> str:
try:
values = json.loads(row.get("property_values_json") or "{}")
except (TypeError, json.JSONDecodeError):
values = {}
event = {"title": row.get("title") or "Untitled",
"start": _row_date(values, prop_id, prop_name),
"description": ""}
if link["provider"] == "google":
return await google_push_event(creds, link.get("calendar_id") or "primary",
event, remote_id)
return await caldav_push_event(creds, event, remote_id)
def _notify_conflict(conn, link: dict, row: dict, ev: dict) -> None:
try:
from app.services.notifications import create_notification
create_notification(
link["user_id"], link["user_id"], "calendar",
"Calendar sync conflict",
f"« {row.get('title') or 'Untitled'} » changed on both sides;"
f" kept the newest ({ev.get('start', '')[:10]}). Edit the row to resolve.",
resource_type="collection", resource_id=link.get("collection_id") or 0,
url=f"/db/{link.get('collection_id')}", conn=conn, commit=False)
except Exception: # noqa: BLE001 — notify must never break sync
pass
async def calendar_sync_scheduler(interval_seconds: int = 900) -> None:
"""Background loop: sync every link with a collection (15 min default)."""
while True:
try:
with get_conn() as conn:
ids = [r["id"] for r in conn.execute(
"SELECT id FROM calendar_links WHERE collection_id IS NOT NULL"
).fetchall()]
for link_id in ids:
try:
await sync_link(link_id)
except Exception as exc: # noqa: BLE001 — one link must not kill the loop
logger.debug("calendar sync link %s failed: %s", link_id, exc)
except Exception as exc: # noqa: BLE001
logger.debug("calendar_sync_scheduler: %s", exc)
await asyncio.sleep(interval_seconds)
# ── free/busy ──────────────────────────────────────────────────────────────
def freebusy(collection_id: int, date_from: str, date_to: str,
date_property: str = "") -> dict:
"""Busy/free weekdays in [date_from, date_to] (day granularity).
Expands recurrence rules server-side (``recurrence.expand_rule``).
"""
from app.services import recurrence as _rec
try:
start = datetime.strptime(date_from[:10], "%Y-%m-%d").date()
end = datetime.strptime(date_to[:10], "%Y-%m-%d").date()
except ValueError:
raise ValueError("use YYYY-MM-DD dates") from None
if end < start or (end - start).days > 370:
raise ValueError("range must be 1..370 days")
with get_conn() as conn:
date_prop = _date_prop_id(conn, collection_id, date_property)
if not date_prop:
raise ValueError("collection has no date property")
prop_id, prop_name = date_prop
rows = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,)).fetchall()
busy: set[str] = set()
for r in rows:
try:
values = json.loads(r["property_values_json"] or "{}")
except (TypeError, json.JSONDecodeError):
continue
base = _row_date(values, prop_id, prop_name)
if not base:
continue
rec = (values.get("__recurrence__") or {})
rule = rec.get(prop_id) or rec.get(prop_name)
if rule:
try:
for occ in _rec.expand_rule(
base, rule, start.isoformat(), end.isoformat()):
busy.add(occ[:10])
except Exception: # noqa: BLE001 — bad rule, use base date only
busy.add(base[:10])
else:
if start.isoformat() <= base[:10] <= end.isoformat():
busy.add(base[:10])
days, free = [], []
day = start
while day <= end:
iso = day.isoformat()
days.append({"date": iso, "busy": iso in busy,
"weekend": day.weekday() >= 5})
if iso not in busy and day.weekday() < 5:
free.append(iso)
day += timedelta(days=1)
return {"collection_id": collection_id, "from": start.isoformat(),
"to": end.isoformat(), "days": days, "free_weekdays": free}
+4
View File
@@ -639,6 +639,10 @@ def blocks_to_html(blocks: list) -> str:
icon = b.get("icon") or "💡"
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
elif t in ("mermaid", "equation_inline", "progress"):
# v7.3.0 blocks — server-rendered so export embeds real content
from app.services.wiki_blocks import render_block
parts.append(render_block(b))
elif t == "image":
src = b.get("src") or ""
alt = _text(b.get("alt"))
+27
View File
@@ -73,6 +73,33 @@ class GiteaClient:
self._set_cache(cache_key, data)
return data
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata for an unfurl card (owner/name/branch/…)."""
cache_key = f"repo_info:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}",
headers=self._headers,
)
resp.raise_for_status()
info = resp.json()
repo_info = {
"id": info.get("id"),
"name": info.get("name"),
"owner": (info.get("owner") or {}).get("login", owner),
"full_name": info.get("full_name") or f"{owner}/{repo}",
"clone_url": info.get("clone_url", ""),
"html_url": info.get("html_url", ""),
"default_branch": info.get("default_branch", "main"),
"description": info.get("description") or "",
"language": info.get("language") or "",
}
self._set_cache(cache_key, repo_info)
return repo_info
async def get_user_orgs(self) -> list[dict]:
cache_key = "user_orgs"
cached = self._cached(cache_key)
+112
View File
@@ -0,0 +1,112 @@
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper)
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
Without ``STT_COMMAND`` the server stores the audio and accepts a manual
``transcript`` (client-side transcription). Nothing here requires new pip
dependencies. See ``docs/V71_Calendar_Meetings.md``.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from pathlib import Path
from app.db import get_conn
logger = logging.getLogger(__name__)
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
MAX_AUDIO_BYTES = 100 * 1024 * 1024
class TranscriptionUnavailable(RuntimeError):
"""Raised when no transcription backend is configured."""
def meetings_dir() -> Path:
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
d = root / "uploads" / "meetings"
d.mkdir(parents=True, exist_ok=True)
return d
def save_transcript(page_id: int, transcript: str, language: str = "fr",
audio_path: str = "") -> int:
with get_conn() as conn:
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
raise ValueError("page not found")
cur = conn.execute(
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
VALUES (?,?,?,?)""",
(page_id, audio_path, transcript or "", language or "fr"))
conn.commit()
return cur.lastrowid
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
(command must print or be adapted — stdout is preferred). Raises
:class:`TranscriptionUnavailable` when unconfigured.
"""
cmd_template = os.environ.get("STT_COMMAND", "").strip()
if not cmd_template:
raise TranscriptionUnavailable(
"no transcription backend (set STT_COMMAND or POST a manual transcript)")
if shutil.which(cmd_template.split()[0]) is None:
raise TranscriptionUnavailable(f"STT command not found: {cmd_template.split()[0]}")
try:
proc = subprocess.run(cmd_template.split() + [audio_path], # noqa: S603 — admin-configured
capture_output=True, text=True, timeout=600)
except subprocess.TimeoutExpired as exc:
raise TranscriptionUnavailable("transcription timed out") from exc
text = (proc.stdout or "").strip()
if proc.returncode != 0 or not text:
raise TranscriptionUnavailable(
f"transcription failed: {(proc.stderr or '')[:300]}")
return text
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
"""Summarize a stored transcript + fire ``meeting.summarized``.
Returns {transcript_id, summary, offline}. Emits the automation event so
custom agents (update tracker, post recap, file tickets) trigger.
"""
from app.services.ai_writing import AIWritingService
with get_conn() as conn:
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
(transcript_id,)).fetchone()
if not row:
raise ValueError("transcript not found")
tr = dict(row)
if not (tr.get("transcript") or "").strip():
raise ValueError("transcript is empty — transcribe first")
svc = AIWritingService(user_id=user_id)
res = await svc.run("summarize", context=tr["transcript"])
summary = (res.get("text") or "").strip() if res.get("ok") else ""
if not summary:
raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}")
with get_conn() as conn:
conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?",
(summary, transcript_id))
page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone()
conn.commit()
try:
from app.services.automations import fire_event
await fire_event("meeting.summarized", {
"page_id": tr["page_id"] if page else 0,
"transcript_id": transcript_id,
"language": tr.get("language") or "fr",
})
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
logger.debug("meeting.summarized dispatch failed: %s", exc)
return {"transcript_id": transcript_id, "summary": summary,
"offline": bool(res.get("offline"))}
+4
View File
@@ -96,6 +96,10 @@ PROPERTY_TYPES: dict[str, dict] = {
"storage": "auto — {id, login}",
"default": None,
},
"button": {
"storage": "none — runs linked automation (button_automation_id)",
"default": None,
},
}
# CSV-friendly subset (no relation/rollup/formula)
+535
View File
@@ -0,0 +1,535 @@
"""FlowDeck — semantic (vector) search + Ask AI (v6.9.0).
Hybrid retrieval = lexical (FTS5/LIKE via :mod:`app.services.search`) fused
with vector cosine similarity via Reciprocal Rank Fusion, then filtered
through :class:`PermissionManager` so unauthorized chunks never surface
(and never enter an LLM prompt).
Vectors use a dependency-free **hashed TF** encoder (``hash-256``): token →
``md5 % 256`` with L2 normalization. Deterministic, offline-first, good
enough for recall on small workspaces; the ``embed_texts`` entry point is
pluggable should an LLM ``/embeddings`` provider be wired later.
See ``docs/V69_Search_Ask_AI.md``.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import math
import re
import struct
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
DIM = 256
MODEL = "hash-256"
CHUNK_SIZE = 1200
CHUNK_OVERLAP = 150
MAX_CHUNKS_PER_RESOURCE = 50
RRF_K = 60
_TOKEN_RE = re.compile(r"[\wÀ-ÿ]+", flags=re.UNICODE)
# Ask cache: (question_hash, workspace_id, user_id) -> (expires_at, payload)
_ask_cache: dict[tuple[str, int | None, int], tuple[float, dict]] = {}
_ASK_CACHE_TTL = 600.0
# Ask rate limit: user_id -> (window_start, count)
_ask_rate: dict[int, tuple[float, int]] = {}
_ASK_RATE_MAX = 30
_ASK_RATE_WINDOW = 60.0
# ── text extraction & chunking ─────────────────────────────────────────────
def _blocks_to_text(blocks) -> list[str]:
parts: list[str] = []
def _walk(items) -> None:
for b in items or []:
if not isinstance(b, dict):
continue
for key in ("content", "text", "title"):
val = b.get(key)
if isinstance(val, str) and val.strip():
parts.append(val.strip())
break
children = b.get("children")
if isinstance(children, list):
_walk(children)
_walk(blocks if isinstance(blocks, list) else [])
return parts
def extract_page_text(content: str | None, content_format: str | None) -> str:
"""Full searchable text of a ``pages`` row (all blocks, recursive)."""
if not content:
return ""
if (content_format or "blocks") == "blocks":
try:
blocks = json.loads(content)
return "\n".join(_blocks_to_text(blocks))
except Exception:
return content
return content
def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
"""Split text into overlapping chunks (char-based, word-boundary aware)."""
text = (text or "").strip()
if not text:
return []
if len(text) <= size:
return [text]
chunks: list[str] = []
start = 0
while start < len(text):
end = min(start + size, len(text))
if end < len(text):
space = text.rfind(" ", start, end)
if space > start + size // 2:
end = space
chunks.append(text[start:end].strip())
if end >= len(text):
break
start = max(end - overlap, start + 1)
if len(chunks) >= MAX_CHUNKS_PER_RESOURCE:
break
return [c for c in chunks if c]
# ── hashed-TF embeddings ───────────────────────────────────────────────────
def _tokens(text: str) -> list[str]:
return [t.lower() for t in _TOKEN_RE.findall(text or "") if t]
def embed_text(text: str, dim: int = DIM) -> bytes:
"""Deterministic L2-normalized hashed-TF vector, struct-packed float32."""
vec = [0.0] * dim
for tok in _tokens(text):
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % dim
vec[idx] += 1.0
norm = math.sqrt(sum(v * v for v in vec))
if norm > 0:
vec = [v / norm for v in vec]
return struct.pack(f"<{dim}f", *vec)
def embed_texts(texts: list[str], dim: int = DIM) -> list[bytes]:
"""Batch entry point (pluggable: LLM /embeddings can replace hashing)."""
return [embed_text(t, dim) for t in texts]
def cosine(a: bytes, b: bytes, dim: int = DIM) -> float:
"""Cosine similarity of two packed normalized vectors (== dot product)."""
try:
va = struct.unpack(f"<{dim}f", a)
vb = struct.unpack(f"<{dim}f", b)
except struct.error:
return 0.0
return sum(x * y for x, y in zip(va, vb, strict=True))
# ── indexing ───────────────────────────────────────────────────────────────
def _resource_text(conn, resource_type: str, resource_id: int) -> str | None:
"""Return indexable text, or None when the resource must not be indexed."""
if resource_type == "page":
row = conn.execute(
"SELECT title, content, content_format FROM pages "
"WHERE id=? AND (deleted_at IS NULL OR deleted_at='') "
"AND COALESCE(search_excluded, 0)=0",
(resource_id,),
).fetchone()
if not row:
return None
body = extract_page_text(row["content"], row["content_format"])
return f"{row['title'] or ''}\n{body}".strip()
if resource_type == "collection":
row = conn.execute(
"SELECT name, description FROM collections WHERE id=?", (resource_id,)
).fetchone()
if not row:
return None
return f"{row['name'] or ''}\n{row['description'] or ''}".strip()
return None
def index_resource(resource_type: str, resource_id: int) -> int:
"""(Re)index one resource. Returns the number of chunks stored."""
with get_conn() as conn:
text = _resource_text(conn, resource_type, resource_id)
conn.execute(
"DELETE FROM semantic_embeddings WHERE resource_type=? AND resource_id=?",
(resource_type, resource_id),
)
n = 0
if text:
for i, chunk in enumerate(chunk_text(text)[:MAX_CHUNKS_PER_RESOURCE]):
conn.execute(
"""INSERT INTO semantic_embeddings
(resource_type, resource_id, chunk_id, chunk_text, embedding, model)
VALUES (?, ?, ?, ?, ?, ?)""",
(resource_type, resource_id, i, chunk, embed_text(chunk), MODEL),
)
n += 1
conn.execute(
"""INSERT INTO semantic_index_state (resource_type, resource_id, indexed_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(resource_type, resource_id)
DO UPDATE SET indexed_at=CURRENT_TIMESTAMP""",
(resource_type, resource_id),
)
conn.commit()
return n
def _stale_resources(conn, limit: int) -> list[tuple[str, int]]:
out: list[tuple[str, int]] = []
rows = conn.execute(
"""SELECT p.id, p.updated_at FROM pages p
LEFT JOIN semantic_index_state s
ON s.resource_type='page' AND s.resource_id=p.id
WHERE (p.deleted_at IS NULL OR p.deleted_at='')
AND COALESCE(p.search_excluded, 0)=0
AND (s.indexed_at IS NULL OR p.updated_at > s.indexed_at)
ORDER BY p.updated_at DESC LIMIT ?""",
(limit,),
).fetchall()
out += [("page", r["id"]) for r in rows]
if len(out) < limit:
rows = conn.execute(
"""SELECT c.id, c.updated_at FROM collections c
LEFT JOIN semantic_index_state s
ON s.resource_type='collection' AND s.resource_id=c.id
WHERE s.indexed_at IS NULL OR c.updated_at > s.indexed_at
ORDER BY c.updated_at DESC LIMIT ?""",
(limit - len(out),),
).fetchall()
out += [("collection", r["id"]) for r in rows]
return out
def purge_orphans() -> int:
"""Drop vectors for deleted/excluded resources. Returns rows removed."""
with get_conn() as conn:
cur = conn.execute(
"""DELETE FROM semantic_embeddings
WHERE (resource_type='page' AND resource_id NOT IN (
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
AND COALESCE(search_excluded, 0)=0))
OR (resource_type='collection' AND resource_id NOT IN (
SELECT id FROM collections))"""
)
conn.execute(
"""DELETE FROM semantic_index_state
WHERE (resource_type='page' AND resource_id NOT IN (
SELECT id FROM pages WHERE (deleted_at IS NULL OR deleted_at='')
AND COALESCE(search_excluded, 0)=0))
OR (resource_type='collection' AND resource_id NOT IN (
SELECT id FROM collections))"""
)
conn.commit()
return cur.rowcount or 0
def index_pending(limit: int = 50) -> dict:
"""Index up to ``limit`` stale resources + purge orphans (scheduler job)."""
with get_conn() as conn:
stale = _stale_resources(conn, limit)
indexed = 0
for rtype, rid in stale:
try:
index_resource(rtype, rid)
indexed += 1
except Exception as exc: # never break the scheduler loop
logger.debug("semantic index failed for %s %s: %s", rtype, rid, exc)
purged = purge_orphans()
return {"checked": len(stale), "indexed": indexed, "purged": purged}
async def semantic_index_scheduler(interval_seconds: int = 300) -> None:
"""Background task: incremental indexing (wired in app lifespan)."""
while True:
try:
await asyncio.to_thread(index_pending)
except Exception as exc: # noqa: BLE001 — scheduler must survive
logger.debug("semantic index scheduler: %s", exc)
await asyncio.sleep(interval_seconds)
# ── vector search ──────────────────────────────────────────────────────────
def vector_search(query: str, *, limit: int = 20,
resource_types: tuple[str, ...] = ("page", "collection")) -> list[dict]:
"""Brute-force cosine scan (fine at this scale). Returns ranked chunks."""
q = (query or "").strip()
if not q:
return []
qvec = embed_text(q)
with get_conn() as conn:
placeholders = ",".join("?" for _ in resource_types)
rows = conn.execute(
f"""SELECT resource_type, resource_id, chunk_id, chunk_text
FROM semantic_embeddings WHERE resource_type IN ({placeholders})""",
list(resource_types),
).fetchall()
scored = []
for r in rows:
row = conn.execute(
"SELECT embedding FROM semantic_embeddings "
"WHERE resource_type=? AND resource_id=? AND chunk_id=?",
(r["resource_type"], r["resource_id"], r["chunk_id"]),
).fetchone()
s = cosine(qvec, row["embedding"]) if row else 0.0
if s > 0:
scored.append({
"resource_type": r["resource_type"],
"resource_id": r["resource_id"],
"chunk_id": r["chunk_id"],
"chunk_text": r["chunk_text"],
"score": s,
})
scored.sort(key=lambda d: d["score"], reverse=True)
return scored[:limit]
# ── hybrid (lexical + vector, RRF) + ACL ───────────────────────────────────
def _rrf_fuse(ranked_lists: list[list[tuple[str, int]]], k: int = RRF_K) -> list[tuple[str, int, float]]:
scores: dict[tuple[str, int], float] = {}
for ranked in ranked_lists:
for rank, key in enumerate(ranked):
scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank + 1)
fused = [(t, i, s) for (t, i), s in scores.items()]
fused.sort(key=lambda x: x[2], reverse=True)
return fused
def hybrid_search(query: str, user: dict, *, limit: int = 20,
workspace_id: int | None = None,
resource_types: tuple[str, ...] = ("page", "collection")) -> tuple[list[dict], int]:
"""Lexical + vector fusion, workspace-scoped, ACL-filtered.
Returns (results, total). Each result: {type, id, title, excerpt, url, score}.
"""
from app.services.permission_manager import PermissionManager
q = (query or "").strip()
if not q:
return [], 0
limit = max(1, min(int(limit or 20), 100))
user_id = user.get("id")
pm = PermissionManager(user_id, bool(user.get("is_admin")))
# 1) lexical candidates (already workspace-membership scoped)
from app.services import search as search_service
lex = search_service.search(q, user_id, limit * 3)
lex_ranked: list[tuple[str, int]] = []
lex_by_key: dict[tuple[str, int], dict] = {}
for item in (lex.get("pages") or []) + (lex.get("collections") or []):
key = (item["type"], item["id"])
if key not in lex_by_key:
lex_by_key[key] = item
lex_ranked.append(key)
# 2) vector candidates
vec = vector_search(q, limit=limit * 3, resource_types=resource_types)
vec_ranked = [(d["resource_type"], d["resource_id"]) for d in vec]
# 3) fuse
fused = _rrf_fuse([lex_ranked, vec_ranked])
# 4) ACL + workspace filter, enrich
results: list[dict] = []
with get_conn() as conn:
for rtype, rid, score in fused:
if rtype == "page":
if not pm.can_view_page(rid):
continue
row = conn.execute(
"SELECT id, title, content, content_format, workspace_id, "
"COALESCE(search_excluded, 0) AS excluded "
"FROM pages WHERE id=?", (rid,)).fetchone()
if not row or row["excluded"]:
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
excerpt = (lex_by_key.get((rtype, rid), {}).get("excerpt")
or extract_page_text(row["content"], row["content_format"])[:160])
results.append({"type": "page", "id": rid,
"title": (row["title"] or "Untitled"),
"excerpt": excerpt, "url": f"/pages/{rid}",
"score": round(score, 5)})
else:
if not pm.can_view_collection(rid):
continue
row = conn.execute(
"SELECT id, name, description, workspace_id FROM collections WHERE id=?",
(rid,)).fetchone()
if not row:
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
excerpt = (lex_by_key.get((rtype, rid), {}).get("subtitle")
or (row["description"] or "")[:160])
results.append({"type": "collection", "id": rid,
"title": (row["name"] or "Untitled"),
"excerpt": excerpt, "url": f"/db/{rid}",
"score": round(score, 5)})
if len(results) >= limit:
break
return results, len(results)
# ── Ask AI ─────────────────────────────────────────────────────────────────
def _check_ask_rate(user_id: int) -> None:
from fastapi import HTTPException
now = time.time()
start, count = _ask_rate.get(user_id, (now, 0))
if now - start > _ASK_RATE_WINDOW:
_ask_rate[user_id] = (now, 1)
return
if count >= _ASK_RATE_MAX:
raise HTTPException(429, "Too many questions. Slow down.")
_ask_rate[user_id] = (start, count + 1)
def _offline_answer(question: str, chunks: list[dict]) -> str:
"""Extractive fallback: top sentences sharing query terms + citations."""
qterms = {t.lower() for t in _tokens(question)}
picked: list[str] = []
for ch in chunks[:8]:
for sent in re.split(r"(?<=[.!?])\s+", ch["chunk_text"] or ""):
words = {t.lower() for t in _tokens(sent)}
if qterms & words and len(sent.strip()) > 20:
picked.append((sent.strip(), ch))
if len(picked) >= 4:
break
if len(picked) >= 4:
break
if not picked:
# No lexical overlap: still cite the top vector matches.
lines = []
for ch in chunks[:3]:
snippet = (ch["chunk_text"] or "")[:200].replace("\n", " ")
lines.append(f"- {snippet} [[fdpage:{ch['resource_id']}]]"
if ch["resource_type"] == "page" else f"- {snippet}")
return ("Je n'ai pas trouvé de passage répondant directement, "
"mais voici les passages les plus proches :\n" + "\n".join(lines))
lines = []
for sent, ch in picked:
if ch["resource_type"] == "page":
lines.append(f"- {sent} [[fdpage:{ch['resource_id']}]]")
else:
lines.append(f"- {sent}")
return "Voici ce que j'ai trouvé dans votre workspace :\n" + "\n".join(lines)
def _resolve_citations(conn, chunks: list[dict]) -> list[dict]:
seen: list[dict] = []
done: set[tuple[str, int]] = set()
for ch in chunks:
key = (ch["resource_type"], ch["resource_id"])
if key in done:
continue
done.add(key)
if ch["resource_type"] == "page":
row = conn.execute("SELECT title FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
seen.append({"type": "page", "id": ch["resource_id"],
"title": (row["title"] if row else "Deleted page") or "Untitled"})
else:
row = conn.execute("SELECT name FROM collections WHERE id=?",
(ch["resource_id"],)).fetchone()
seen.append({"type": "collection", "id": ch["resource_id"],
"title": (row["name"] if row else "Deleted") or "Untitled"})
return seen
async def ask(question: str, user: dict, workspace_id: int | None = None) -> dict:
"""RAG answer over the user's authorized chunks (LLM or offline fallback)."""
from app.services.permission_manager import PermissionManager
q = (question or "").strip()
if not q:
from fastapi import HTTPException
raise HTTPException(400, "question is required")
_check_ask_rate(user.get("id") or 0)
cache_key = (hashlib.sha256(q.encode()).hexdigest(), workspace_id, user.get("id"))
now = time.time()
hit = _ask_cache.get(cache_key)
if hit and hit[0] > now:
out = dict(hit[1])
out["cached"] = True
return out
pm = PermissionManager(user.get("id"), bool(user.get("is_admin")))
vec = vector_search(q, limit=24)
allowed = []
with get_conn() as conn:
for ch in vec:
if ch["resource_type"] == "page":
row = conn.execute(
"SELECT workspace_id, COALESCE(search_excluded, 0) AS excluded "
"FROM pages WHERE id=?", (ch["resource_id"],)).fetchone()
if not row or row["excluded"] or not pm.can_view_page(ch["resource_id"]):
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
else:
row = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?",
(ch["resource_id"],)).fetchone()
if not row or not pm.can_view_collection(ch["resource_id"]):
continue
if workspace_id and row["workspace_id"] != workspace_id:
continue
allowed.append(ch)
if len(allowed) >= 8:
break
answer = ""
offline = True
if allowed:
try:
from app.services.llm_client import LLMClient
llm = LLMClient()
if await llm.is_available():
ctx = "\n\n".join(
f"[doc {i+1} page_id={c['resource_id']}]\n{c['chunk_text'][:1500]}"
for i, c in enumerate(allowed)
)
resp = await llm.complete([
{"role": "system",
"content": "Réponds en français en citant les sources avec "
"[[fdpage:ID]] (ID = page_id indiqué). Concis."},
{"role": "user", "content": f"Question : {q}\n\nContexte :\n{ctx}"},
])
answer = (resp.text or "").strip()
offline = False
except Exception as exc: # noqa: BLE001 — fall back to extractive
logger.debug("ask LLM failed, offline fallback: %s", exc)
if not answer:
answer = ("Aucun contenu accessible ne correspond à votre question."
if not allowed else _offline_answer(q, allowed))
with get_conn() as conn:
citations = _resolve_citations(conn, allowed[:8])
out = {"answer_markdown": answer, "citations": citations,
"offline": offline, "cached": False}
_ask_cache[cache_key] = (now + _ASK_CACHE_TTL, out)
return out
def reset_state() -> None:
"""Test helper: clear ask cache + rate limiter."""
_ask_cache.clear()
_ask_rate.clear()
+136
View File
@@ -0,0 +1,136 @@
"""FlowDeck — TOTP 2FA + backup codes (v7.2.0).
Secrets are Fernet-encrypted at rest (same construction as SSO secrets).
Login flow: ``POST /auth/local-login`` returns ``2fa_required`` + a short-lived
signed ``pending`` token; ``POST /auth/local-verify`` exchanges it for a
session. See ``docs/V72_Enterprise_SCIM_2FA.md``.
"""
from __future__ import annotations
import hashlib
import json
import secrets
from app.db import get_conn
BACKUP_CODE_COUNT = 10
def _fernet():
import base64
from cryptography.fernet import Fernet
from app.config import settings
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def is_enabled(user_id: int) -> bool:
with get_conn() as conn:
row = conn.execute("SELECT totp_secret_enc FROM users WHERE id=?",
(user_id,)).fetchone()
if not row or not row["totp_secret_enc"]:
return False
try:
return bool(_fernet().decrypt(row["totp_secret_enc"].encode()).decode())
except Exception: # noqa: BLE001
return False
def setup_secret(user_id: int) -> dict:
"""Create a new TOTP secret (not yet active until verified)."""
import pyotp
secret = pyotp.random_base32()
with get_conn() as conn:
row = conn.execute("SELECT login, email FROM users WHERE id=?", (user_id,)).fetchone()
label = (row["email"] or row["login"]) if row else f"user{user_id}"
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name="FlowDeck")
return {"secret": secret, "otpauth_url": uri}
def activate_secret(user_id: int, secret: str, code: str) -> list[str]:
"""Verify ``code`` against ``secret``; on success store + return backup codes."""
import pyotp
if not pyotp.TOTP(secret).verify(code, valid_window=1):
raise ValueError("invalid code")
codes = [secrets.token_hex(4) for _ in range(BACKUP_CODE_COUNT)]
hashes = [hashlib.sha256(c.encode()).hexdigest() for c in codes]
with get_conn() as conn:
conn.execute("UPDATE users SET totp_secret_enc=?, totp_backup_hashes=? WHERE id=?",
(_fernet().encrypt(secret.encode()).decode(),
json.dumps(hashes), user_id))
conn.commit()
return codes
def verify_code(user_id: int, code: str) -> bool:
"""Check a TOTP code or consume a backup code."""
code = (code or "").strip().replace(" ", "")
if not code:
return False
with get_conn() as conn:
row = conn.execute("SELECT totp_secret_enc, totp_backup_hashes FROM users WHERE id=?",
(user_id,)).fetchone()
if not row or not row["totp_secret_enc"]:
return False
try:
secret = _fernet().decrypt(row["totp_secret_enc"].encode()).decode()
except Exception: # noqa: BLE001
return False
import pyotp
if secret and pyotp.TOTP(secret).verify(code, valid_window=1):
return True
# backup codes (single use)
try:
hashes = json.loads(row["totp_backup_hashes"] or "[]")
except (TypeError, json.JSONDecodeError):
hashes = []
digest = hashlib.sha256(code.encode()).hexdigest()
if digest in hashes:
hashes.remove(digest)
with get_conn() as conn:
conn.execute("UPDATE users SET totp_backup_hashes=? WHERE id=?",
(json.dumps(hashes), user_id))
conn.commit()
return True
return False
def disable(user_id: int) -> None:
with get_conn() as conn:
conn.execute("UPDATE users SET totp_secret_enc='', totp_backup_hashes='[]'"
" WHERE id=?", (user_id,))
conn.commit()
def remaining_backup_codes(user_id: int) -> int:
with get_conn() as conn:
row = conn.execute("SELECT totp_backup_hashes FROM users WHERE id=?",
(user_id,)).fetchone()
try:
return len(json.loads(row["totp_backup_hashes"] or "[]")) if row else 0
except (TypeError, json.JSONDecodeError):
return 0
# ── pending 2FA challenge (signed, 5 min) ──────────────────────────────────
def mint_pending(user_id: int) -> str:
from itsdangerous import URLSafeTimedSerializer
from app.config import settings
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
return ser.dumps({"user_id": user_id})
def redeem_pending(token: str, max_age: int = 300) -> int | None:
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
ser = URLSafeTimedSerializer(settings.app_secret_key, salt="totp-pending")
try:
payload = ser.loads(token, max_age=max_age)
return int(payload.get("user_id", 0)) or None
except (BadSignature, SignatureExpired, ValueError):
return None
+415
View File
@@ -0,0 +1,415 @@
"""FlowDeck — teamspaces, verified pages, collab polish (v7.3.0).
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
- ``teamspaces`` namespace pages + databases ; ``private=1`` → invisible aux
non-membres (404, comme une collection restricted).
- ``page_verifications`` : badge ✅ avec expiration (90 j par défaut).
- ``page_follows`` → notif ``page.updated`` ; ``comment_reactions`` ;
``guest_shares`` (``/g/<token>``) ; ``page_views`` (compteurs journaliers).
"""
from __future__ import annotations
import datetime
import secrets
from app.db import get_conn
VERIFICATION_DAYS_DEFAULT = 90
# Roles, strongest first. Mirrors collection roles.
TEAMSPACE_ROLES = ("owner", "editor", "commenter", "viewer")
_ROLE_RANK = {r: i for i, r in enumerate(reversed(TEAMSPACE_ROLES))}
def _utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)
def _iso(dt: datetime.datetime) -> str:
return dt.replace(microsecond=0).isoformat()
# ── teamspaces ─────────────────────────────────────────────────────────────
def get_teamspace_role(user_id: int | None, teamspace_id: int) -> str | None:
"""Explicit role, else workspace role, else ``None`` when unreachable."""
if not user_id:
return None
with get_conn() as conn:
ts = conn.execute("SELECT workspace_id, private FROM teamspaces WHERE id=?",
(teamspace_id,)).fetchone()
if not ts:
return None
row = conn.execute("SELECT role FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
(teamspace_id, user_id)).fetchone()
if row:
return row["role"]
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if admin and admin["is_admin"]:
return "owner"
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(ts["workspace_id"],)).fetchone()
if owner and owner["owner_id"] == user_id:
return "owner"
if not ts["private"]:
# a public teamspace is still workspace-scoped: no workspace
# membership means no access (otherwise any logged-in account on
# the instance could read every public teamspace).
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ts["workspace_id"], user_id)).fetchone()
return member["role"] if member else None
return None
def can_read_teamspace(user_id: int | None, teamspace_id: int) -> bool:
return get_teamspace_role(user_id, teamspace_id) is not None
def can_write_teamspace(user_id: int | None, teamspace_id: int) -> bool:
role = get_teamspace_role(user_id, teamspace_id)
return role in ("owner", "editor")
def list_teamspaces(user_id: int, workspace_id: int | None = None) -> list[dict]:
"""Teamspaces the user can see (private ones filtered out).
``workspace_id=None`` lists across every workspace (global sidebar).
"""
with get_conn() as conn:
if workspace_id is not None:
ws_member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id)).fetchone()
if not ws_member:
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(workspace_id,)).fetchone()
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if not owner or owner["owner_id"] != user_id:
if not (admin and admin["is_admin"]):
return []
rows = conn.execute("SELECT * FROM teamspaces WHERE workspace_id=? ORDER BY name",
(workspace_id,)).fetchall()
else:
rows = conn.execute("SELECT * FROM teamspaces ORDER BY workspace_id, name").fetchall()
out = []
for r in rows:
role = get_teamspace_role(user_id, r["id"])
if role is None:
continue
counts = conn.execute(
"""SELECT (SELECT COUNT(*) FROM pages WHERE teamspace_id=?)
+ (SELECT COUNT(*) FROM collections WHERE teamspace_id=?) AS n""",
(r["id"], r["id"])).fetchone()
item = dict(r)
item["role"] = role
item["workspace_name"] = _workspace_name(conn, r["workspace_id"])
item["item_count"] = counts["n"]
out.append(item)
return out
def _workspace_name(conn, workspace_id: int) -> str:
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
return row["name"] if row else ""
def teamspace_pages(teamspace_id: int) -> list[dict]:
"""Non-deleted pages belonging to a teamspace (title/URL order)."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, title, workspace_id, teamspace_id
FROM pages WHERE teamspace_id=? AND deleted_at IS NULL
ORDER BY title""", (teamspace_id,)).fetchall()]
def teamspace_collections(teamspace_id: int) -> list[dict]:
"""Databases belonging to a teamspace."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, name, icon FROM collections
WHERE teamspace_id=? ORDER BY name""", (teamspace_id,)).fetchall()]
def create_teamspace(workspace_id: int, name: str, user_id: int,
description: str = "", private: bool = False) -> int:
with get_conn() as conn:
try:
cur = conn.execute(
"INSERT INTO teamspaces (workspace_id, name, description, private, created_by)"
" VALUES (?,?,?,?,?)",
(workspace_id, name.strip(), description[:400], 1 if private else 0, user_id))
except Exception as exc: # UNIQUE(workspace_id, name)
if "UNIQUE" in str(exc):
raise ValueError("A teamspace with this name already exists") from None
raise
conn.commit()
# the creator is owner
conn.execute("INSERT INTO teamspace_members (teamspace_id, user_id, role)"
" VALUES (?,?,'owner')", (cur.lastrowid, user_id))
conn.commit()
return cur.lastrowid
def teamspace_member_ids(teamspace_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM teamspace_members WHERE teamspace_id=?",
(teamspace_id,)).fetchall()]
# ── verified pages ─────────────────────────────────────────────────────────
def is_expired(row) -> bool:
if not row or not row["expires_at"]:
return False
try:
return _utcnow() > datetime.datetime.fromisoformat(row["expires_at"])
except ValueError:
return False
def verify_page(page_id: int, user_id: int, days: int = VERIFICATION_DAYS_DEFAULT,
note: str = "") -> dict:
days = max(1, min(int(days or VERIFICATION_DAYS_DEFAULT), 365))
expires = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_verifications (page_id, verified_by, note, expires_at)
VALUES (?,?,?,?)
ON CONFLICT(page_id) DO UPDATE SET
verified_by=excluded.verified_by, note=excluded.note,
verified_at=CURRENT_TIMESTAMP, expires_at=excluded.expires_at""",
(page_id, user_id, note[:400], expires))
conn.commit()
return verification(page_id)
def verification(page_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute(
"""SELECT v.*, u.full_name, u.login FROM page_verifications v
LEFT JOIN users u ON u.id = v.verified_by WHERE v.page_id=?""",
(page_id,)).fetchone()
if not row:
return None
d = dict(row)
d["expired"] = is_expired(row)
d["active"] = not d["expired"]
return d
def unverify_page(page_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM page_verifications WHERE page_id=?", (page_id,))
conn.commit()
return bool(cur.rowcount)
def expiring_verifications(days: int = 7) -> list[dict]:
"""Verifications expiring within ``days`` (drives the owner notification).
``pages`` has no owner column in FlowDeck, so the reminder targets the user
who performed the verification.
"""
horizon = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
rows = conn.execute(
"""SELECT v.*, p.title, v.verified_by AS owner_id FROM page_verifications v
JOIN pages p ON p.id = v.page_id
WHERE v.expires_at IS NOT NULL AND v.expires_at <= ?""",
(horizon,)).fetchall()
return [dict(r) for r in rows]
# ── follows ────────────────────────────────────────────────────────────────
def is_following(page_id: int, user_id: int) -> bool:
with get_conn() as conn:
return bool(conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone())
def toggle_follow(page_id: int, user_id: int) -> bool:
"""Returns the new state (True = now following)."""
with get_conn() as conn:
if conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
conn.execute("DELETE FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id))
conn.commit()
return False
conn.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
conn.commit()
return True
def ensure_follow(page_id: int, user_id: int, conn=None) -> bool:
"""Follow unless already following. Returns True when newly followed."""
if not user_id:
return False
def _run(c):
if c.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
return False
c.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
return True
if conn is not None:
added = _run(conn)
conn.commit()
return added
with get_conn() as _c:
added = _run(_c)
_c.commit()
return added
def followers(page_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()]
# Rate-limit window for ``page.updated`` notifications: the block editor
# autosaves every ~1.5 s; without it followers would be spammed per keystroke.
_UPDATE_NOTIF_WINDOW_MIN = 10
def notify_followers_of_page_update(page_id: int, actor_id: int | None,
title: str = "") -> int:
"""Create a ``page.updated`` notification for every follower (except the
actor), throttled to one per :data:`_UPDATE_NOTIF_WINDOW_MIN` minutes.
Returns the number of notifications created."""
if not page_id:
return 0
from app.services.notifications import create_notification
with get_conn() as conn:
followed = conn.execute("SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()
if not followed:
return 0
page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
title = (title or (page["title"] if page else "") or "Untitled")
created = 0
for f in followed:
uid = f["user_id"]
if uid == actor_id:
continue
recent = conn.execute(
"""SELECT 1 FROM notifications
WHERE user_id=? AND resource_type='page' AND resource_id=?
AND ntype='page.updated'
AND created_at >= datetime('now', ?)""",
(uid, page_id, f"-{_UPDATE_NOTIF_WINDOW_MIN} minutes")).fetchone()
if recent:
continue
create_notification(
uid, actor_id, "page.updated",
title=f'"{title}" was updated',
message=f'Page "{title}" has been modified',
resource_type="page", resource_id=page_id,
url=f"/pages/{page_id}", conn=conn, commit=False,
)
created += 1
conn.commit()
return created
# ── comment reactions ──────────────────────────────────────────────────────
def toggle_reaction(comment_id: int, user_id: int, emoji: str) -> dict:
"""Add or remove ``emoji``; returns the aggregated counts for the comment."""
emoji = (emoji or "").strip()[:16]
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM comments WHERE id=?", (comment_id,)).fetchone():
raise LookupError("Comment not found")
existing = conn.execute(
"SELECT id FROM comment_reactions WHERE comment_id=? AND user_id=? AND emoji=?",
(comment_id, user_id, emoji)).fetchone()
if existing:
conn.execute("DELETE FROM comment_reactions WHERE id=?", (existing["id"],))
conn.commit()
else:
conn.execute("INSERT INTO comment_reactions (comment_id, user_id, emoji)"
" VALUES (?,?,?)", (comment_id, user_id, emoji))
conn.commit()
return reactions(comment_id)
def reactions(comment_id: int) -> dict[str, dict]:
with get_conn() as conn:
rows = conn.execute(
"""SELECT emoji, COUNT(*) AS n,
GROUP_CONCAT(user_id) AS users
FROM comment_reactions WHERE comment_id=? GROUP BY emoji ORDER BY emoji""",
(comment_id,)).fetchall()
return {r["emoji"]: {"count": r["n"],
"users": [int(u) for u in (r["users"] or "").split(",") if u]}
for r in rows}
# ── guest shares ───────────────────────────────────────────────────────────
def create_guest_share(page_id: int, email: str, role: str, created_by: int,
days: int | None = 30) -> dict:
if role not in ("viewer", "commenter"):
raise ValueError("role must be viewer or commenter")
token = f"g_{secrets.token_urlsafe(24)}"
expires = _iso(_utcnow() + datetime.timedelta(days=days)) if days else None
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO guest_shares (page_id, email, token, role, created_by, expires_at)
VALUES (?,?,?,?,?,?)""",
(page_id, email[:200], token, role, created_by, expires))
conn.commit()
return dict(conn.execute("SELECT * FROM guest_shares WHERE id=?",
(cur.lastrowid,)).fetchone())
def resolve_guest_share(token: str) -> dict | None:
"""Active share for ``token``, or ``None`` (unknown / revoked / expired)."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM guest_shares WHERE token=?", (token,)).fetchone()
if not row or row["revoked"]:
return None
if row["expires_at"]:
try:
if _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]):
return None
except ValueError:
pass
return dict(row)
# ── page views ─────────────────────────────────────────────────────────────
def record_view(page_id: int, day: str | None = None) -> int:
day = day or _utcnow().strftime("%Y-%m-%d")
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_views (page_id, day, views) VALUES (?,?,1)
ON CONFLICT(page_id, day) DO UPDATE SET views = views + 1""",
(page_id, day))
conn.commit()
row = conn.execute("SELECT views FROM page_views WHERE page_id=? AND day=?",
(page_id, day)).fetchone()
return row["views"]
def view_stats(page_id: int, days: int = 30) -> dict:
days = max(1, min(int(days or 30), 365))
since = (_utcnow() - datetime.timedelta(days=days - 1)).strftime("%Y-%m-%d")
with get_conn() as conn:
rows = conn.execute(
"SELECT day, views FROM page_views WHERE page_id=? AND day>=? ORDER BY day",
(page_id, since)).fetchall()
series = {r["day"]: r["views"] for r in rows}
# fill the gap so charts have no holes
out, cursor = [], _utcnow() - datetime.timedelta(days=days - 1)
for _ in range(days):
key = cursor.strftime("%Y-%m-%d")
out.append({"day": key, "views": series.get(key, 0)})
cursor += datetime.timedelta(days=1)
return {"page_id": page_id, "days": days, "total": sum(series.values()),
"series": out}
+196
View File
@@ -0,0 +1,196 @@
"""FlowDeck — v7.3.0 blocks: mermaid, equation_inline, progress.
Server-side rendering so HTML/PDF export embeds real content (the editor
already has Prism + KaTeX client-side). Design §2 of
``docs/V73_Wiki_Teamspaces_Polish.md``.
- ``mermaid`` : SVG via ``mmdc`` when installed, else a ``<pre>`` fallback
that mermaid.js can still render in the browser.
- ``equation_inline`` : KaTeX delimiters wrapped in a span (client auto-render);
falls back to readable plaintext.
- ``progress`` : ``{"rollup_ref": {collection_id, property_id}}`` → percent
bar computed with :class:`RollupEngine`.
"""
from __future__ import annotations
import html
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
# ── mermaid ────────────────────────────────────────────────────────────────
def mmdc_available() -> bool:
return shutil.which("mmdc") is not None
def mermaid_to_svg(source: str, timeout: int = 20) -> str | None:
"""Render mermaid source to an inline SVG, or ``None`` if unavailable.
Uses the local mermaid-cli (``mmdc``) when present; never raises — callers
degrade to the code fallback.
"""
source = (source or "").strip()
if not source or not mmdc_available():
return None
with tempfile.TemporaryDirectory(prefix="fd_mermaid_") as tmp:
src = Path(tmp) / "diagram.mmd"
out = Path(tmp) / "diagram.svg"
src.write_text(source, encoding="utf-8")
try:
subprocess.run(
["mmdc", "-i", str(src), "-o", str(out), "-b", "transparent"],
capture_output=True, timeout=timeout, check=True)
except (subprocess.SubprocessError, OSError):
return None
if not out.exists():
return None
svg = out.read_text(encoding="utf-8", errors="replace")
# strip the XML prolog / doctype so the SVG can be inlined
svg = re.sub(r"<\?xml.*?\?>", "", svg, flags=re.S).strip()
return svg or None
def render_mermaid(source: str) -> str:
"""Inline SVG when possible, else a mermaid-renderable code block."""
svg = mermaid_to_svg(source)
if svg:
return f'<figure class="mermaid-figure">{svg}</figure>'
return (f'<pre class="mermaid"><code class="language-mermaid">'
f'{html.escape(source or "")}</code></pre>')
# ── equations ──────────────────────────────────────────────────────────────
# Only characters that are meaningful inside a math expression are kept. Note
# that `<`, `>` and `\` are NOT allowed: they would let the source close the
# KaTeX delimiter early or inject markup into the exported HTML.
_EQUATION_ALLOWED = re.compile(r"[^0-9A-Za-z\s+\-*/^_=!(){}\[\].|,';:]")
def sanitize_equation(source: str) -> str:
return _EQUATION_ALLOWED.sub("", source or "").strip()
def render_equation(source: str) -> str:
expr = sanitize_equation(source)
if not expr:
return ""
return (f'<span class="fd-equation" data-equation="{html.escape(expr)}">'
f'\\({html.escape(expr)}\\)</span>')
# ── progress ───────────────────────────────────────────────────────────────
def _rows_values(collection_id: int) -> list[dict]:
import json as _json
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,)).fetchall()
out = []
for r in rows:
try:
out.append(_json.loads(r["property_values_json"] or "{}"))
except ValueError:
out.append({})
return out
_DONE_TRUE = (True, 1, "true", "done", "yes", "checked", "✓", "complete", "completed")
def _is_done(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in _DONE_TRUE
return False
def progress_value(rollup_ref: dict | None) -> dict:
"""Resolve ``{collection_id, done_property_id | property_id, func}`` to a
0..100 percent.
Aggregates directly over ``collection_pages.property_values_json`` (the
relation-based :class:`RollupEngine` needs a relation chain + a source
page, which a page-embedded progress bar does not have).
"""
from app.db import get_conn
if not rollup_ref or not rollup_ref.get("collection_id"):
return {"percent": None, "done": None, "total": None}
cid = rollup_ref["collection_id"]
values = _rows_values(cid)
total = len(values)
done_pid = rollup_ref.get("done_property_id")
if done_pid:
key = str(done_pid)
done = sum(1 for vals in values if _is_done(vals.get(key)))
if total:
return {"percent": round(done * 100 / total, 1), "done": done, "total": total}
return {"percent": 0.0, "done": 0, "total": 0}
pid = rollup_ref.get("property_id")
if not pid or not total:
return {"percent": None, "done": None, "total": total}
key = str(pid)
with get_conn() as conn:
prop = conn.execute("SELECT prop_type FROM collection_properties WHERE id=? AND"
" collection_id=?", (pid, cid)).fetchone()
if not prop:
return {"percent": None, "done": None, "total": total}
func = (rollup_ref.get("func") or "count").lower()
if prop["prop_type"] in ("checkbox", "status", "select"):
done = sum(1 for vals in values if _is_done(vals.get(key)))
elif prop["prop_type"] in ("number", "formula", "rollup"):
nums = []
for vals in values:
v = vals.get(key)
try:
nums.append(float(v))
except (TypeError, ValueError):
continue
if not nums:
return {"percent": None, "done": None, "total": total}
done = {"sum": sum, "avg": lambda xs: sum(xs) / len(xs),
"max": max, "min": min}.get(func, len)(nums)
else:
done = sum(1 for vals in values if vals.get(key) not in (None, "", [], {}))
if not total:
return {"percent": 0.0, "done": done, "total": 0}
return {"percent": round(min(float(done) * 100 / total, 100), 1),
"done": done, "total": total}
def render_progress(block: dict) -> str:
stats = progress_value(block.get("rollup_ref") or block)
pct = stats["percent"]
label = block.get("label") or "Progress"
if pct is None:
return (f'<div class="fd-progress" data-percent=""><div class="fd-progress-label">'
f'{html.escape(label)}: —</div></div>')
detail = f"{stats['done']}/{stats['total']}" if stats.get("total") else ""
return (f'<div class="fd-progress" data-percent="{pct}">'
f'<div class="fd-progress-label">{html.escape(label)}: {pct}%'
f'{(" (" + html.escape(detail) + ")") if detail else ""}</div>'
f'<div class="fd-progress-bar"><div class="fd-progress-fill"'
f' style="width:{min(pct, 100)}%"></div></div></div>')
def render_block(block: dict) -> str:
"""Dispatch for the three v7.3 block types (used by export + preview API)."""
t = block.get("type")
if t == "mermaid":
return render_mermaid(block.get("content") or block.get("source") or "")
if t == "equation_inline":
return render_equation(block.get("content") or "")
if t == "progress":
return render_progress(block)
return ""
+234
View File
@@ -0,0 +1,234 @@
"""FlowDeck — Workers lite (v7.0.0).
Custom Python snippets run on FlowDeck infrastructure: manual, on a cron
schedule, or shared across the team (fork). Parité Notion Workers (07/2026),
sans facturation : un budget journalier secondes/workspace fait office de
« credits dashboard ».
Sandbox (documenté, best-effort single-process) :
- AST blacklist : ``import os/sys/subprocess/socket``, ``open()``,
``exec/eval/compile``, attributs dunder.
- Pas de réseau, pas de FS ; builtins restreints (pas de ``__import__``).
- Timeout 30 s (thread + join), budget journalier ``daily_budget_s``.
- Seules API exposées : ``log()``, ``ctx`` (dict), ``result`` (dict out).
Voir ``docs/V70_Automations_Workers.md``.
"""
from __future__ import annotations
import ast
import asyncio
import io
import logging
import re
import time
from contextlib import redirect_stdout
from datetime import UTC, datetime
from app.db import get_conn
logger = logging.getLogger(__name__)
RUN_TIMEOUT_S = 30
MAX_CODE_CHARS = 20_000
MAX_LOG_CHARS = 10_000
_SLUG_RE = re.compile(r"^[a-z0-9-]{3,60}$")
_FORBIDDEN_IMPORTS = {"os", "sys", "subprocess", "socket", "shutil",
"pathlib", "io", "asyncio", "threading", "multiprocessing"}
_FORBIDDEN_CALLS = {"open", "exec", "eval", "compile", "__import__"}
class WorkerRejected(ValueError):
"""Raised when worker code violates the sandbox policy."""
def validate_code(code: str) -> None:
"""AST lint of worker code. Raises WorkerRejected on violation."""
code = code or ""
if len(code) > MAX_CODE_CHARS:
raise WorkerRejected(f"code too long ({len(code)} > {MAX_CODE_CHARS})")
try:
tree = ast.parse(code)
except SyntaxError as exc:
raise WorkerRejected(f"syntax error: {exc}") from None
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
names = [a.name.split(".")[0] for a in node.names]
if getattr(node, "module", None):
names.append(str(node.module).split(".")[0])
for name in names:
if name in _FORBIDDEN_IMPORTS:
raise WorkerRejected(f"import forbidden: {name}")
elif isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in _FORBIDDEN_CALLS:
raise WorkerRejected(f"call forbidden: {func.id}()")
elif isinstance(node, ast.Attribute):
if isinstance(node.attr, str) and node.attr.startswith("__"):
raise WorkerRejected(f"dunder access forbidden: {node.attr}")
def _slugify(name: str) -> str:
import unicodedata
slug = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^\w\s-]", "", slug.lower())
return re.sub(r"[-\s]+", "-", slug).strip("-") or "worker"
def unique_slug(base: str, ignore_id: int | None = None) -> str:
slug, i = _slugify(base)[:60] or "worker", 1
with get_conn() as conn:
while conn.execute(
"SELECT id FROM workers WHERE slug=? AND id != COALESCE(?, -1)",
(slug, ignore_id)).fetchone():
i += 1
slug = f"{_slugify(base)[:55]}-{i}"
return slug
_SAFE_BUILTINS = {
"abs": abs, "all": all, "any": any, "bool": bool, "dict": dict,
"enumerate": enumerate, "filter": filter, "float": float, "format": format,
"frozenset": frozenset, "int": int, "len": len, "list": list, "map": map,
"max": max, "min": min, "range": range, "reversed": reversed, "round": round,
"set": set, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
"zip": zip, "print": print, "isinstance": isinstance, "type": type,
}
def _exec_code(code: str, ctx: dict) -> tuple[dict, str]:
"""Run validated code in a thread. Returns (result_dict, logs)."""
logs: list[str] = []
def _log(*args) -> None:
logs.append(" ".join(str(a) for a in args))
namespace = {"__builtins__": dict(_SAFE_BUILTINS),
"log": _log, "ctx": dict(ctx or {}), "result": {}}
buf = io.StringIO()
with redirect_stdout(buf):
exec(compile(code, "<worker>", "exec"), namespace) # noqa: S102 — sandboxed
printed = buf.getvalue()
if printed:
logs.append(printed)
result = namespace.get("result")
return result if isinstance(result, dict) else {}, "\n".join(logs)[:MAX_LOG_CHARS]
def daily_usage_s(workspace_id: int | None) -> float:
"""CPU seconds consumed today (UTC) by a workspace's workers."""
day = datetime.now(UTC).strftime("%Y-%m-%d")
with get_conn() as conn:
row = conn.execute(
"""SELECT COALESCE(SUM(wr.duration_ms), 0) FROM worker_runs wr
JOIN workers w ON w.id = wr.worker_id
WHERE date(wr.created_at) = date(?)
AND COALESCE(w.workspace_id, -1) = COALESCE(?, -1)""",
(day, workspace_id)).fetchone()
return (row[0] or 0) / 1000.0
def _save_run(worker_id: int, status: str, logs: str, duration_ms: int) -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO worker_runs (worker_id, status, logs, duration_ms)"
" VALUES (?,?,?,?)", (worker_id, status, logs[:MAX_LOG_CHARS], duration_ms))
conn.commit()
return cur.lastrowid
def run_worker(worker_id: int, ctx: dict | None = None) -> dict:
"""Execute a worker synchronously (used by the router + cron loop).
Returns {status, run_id, duration_ms}. Never raises for user-code errors
(they become ``error`` runs); raises only when the worker is missing or
over budget (caller maps to 404/429).
"""
from fastapi import HTTPException
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")
worker = dict(row)
validate_code(worker.get("code_py") or "")
used = daily_usage_s(worker.get("workspace_id"))
if used >= (worker.get("daily_budget_s") or 60):
_save_run(worker_id, "over_budget", f"daily budget exceeded ({used:.1f}s used)", 0)
raise HTTPException(429, "Worker daily budget exceeded")
outcome: dict = {}
def _target() -> None:
try:
result, logs = _exec_code(worker.get("code_py") or "", ctx or {})
outcome["result"] = result
outcome["logs"] = logs
except Exception as exc: # noqa: BLE001 — user code, recorded
outcome["error"] = f"{type(exc).__name__}: {exc}"
import threading
started = time.time()
thread = threading.Thread(target=_target, daemon=True)
thread.start()
thread.join(timeout=RUN_TIMEOUT_S)
duration_ms = int((time.time() - started) * 1000)
if thread.is_alive():
run_id = _save_run(worker_id, "timeout",
f"exceeded {RUN_TIMEOUT_S}s timeout", duration_ms)
return {"status": "timeout", "run_id": run_id, "duration_ms": duration_ms}
if "error" in outcome:
run_id = _save_run(worker_id, "error", outcome["error"], duration_ms)
return {"status": "error", "run_id": run_id,
"duration_ms": duration_ms, "error": outcome["error"]}
run_id = _save_run(worker_id, "ok", outcome.get("logs", ""), duration_ms)
return {"status": "ok", "run_id": run_id, "duration_ms": duration_ms,
"result": outcome.get("result", {})}
async def run_due_workers() -> int:
"""Fire workers whose ``schedule_cron`` is due (called from the 60s loop)."""
from app.services.automations import cron_due
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM workers WHERE schedule_cron IS NOT NULL AND schedule_cron != ''"
).fetchall()
fired = 0
for row in rows:
worker = dict(row)
with get_conn() as conn:
last = conn.execute(
"SELECT MAX(created_at) FROM worker_runs WHERE worker_id=?",
(worker["id"],)).fetchone()[0]
try:
if cron_due(worker["schedule_cron"] or "", last):
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, run_worker, worker["id"], {})
fired += 1
except Exception as exc: # noqa: BLE001 — one worker must not kill the loop
logger.debug("worker %s cron failed: %s", worker["id"], exc)
return fired
def fork_worker(worker_id: int, user_id: int) -> dict:
"""Duplicate a shared worker for another user (Notion-style sharing)."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
if not row:
from fastapi import HTTPException
raise HTTPException(404, "Worker not found")
src = dict(row)
if not src.get("shared") and src.get("created_by") != user_id:
from fastapi import HTTPException
raise HTTPException(403, "Worker is not shared")
slug = unique_slug(f"{src['slug']}-fork")
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, src["workspace_id"], f"{src['name']} (fork)", src["code_py"],
"", 0, src["daily_budget_s"], user_id))
conn.commit()
new_id = cur.lastrowid
return {"id": new_id, "slug": slug, "status": "forked", "from": worker_id}
+1 -1
View File
@@ -2393,7 +2393,7 @@ applyAIBlocks(text){
if(this.saving){ if(cb) cb(); return; }
this.sync();
this.saving=true;
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name','url','title','description','image','site_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
// v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau
const queueOffline=()=>{
if(!window.FlowOffline){this.saving=false;return;}
+48 -8
View File
@@ -404,6 +404,33 @@
</div>
</div>
<!-- Teamspaces -->
<div class="sidebar-section" x-show="isSectionVisible('teamspaces')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('teamspaces')" @contextmenu.prevent="openSectionMenu($event, 'teamspaces')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.teamspaces }">▶</span>
<span class="section-icon">{{ fd_icon("home",14) }}</span>
<span class="section-label">Teamspaces</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="loadTeamspaces()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'teamspaces')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.teamspaces" x-transition>
<ul class="sidebar-items" data-section="teamspaces">
<template x-for="t in teamspaces" :key="t.id">
<li class="sidebar-item" @click="openTeamspace(t.id)">
<span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span>
<span class="page-name" x-text="t.name"></span>
<span class="page-name text-dim" style="font-size:11px;" x-text="t.item_count + (t.private ? ' · 🔒' : '')"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!teamspaces.length"><span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span><span class="page-name text-dim">No teamspaces yet</span></li>
</ul>
</div>
</div>
<!-- Shared -->
<div class="sidebar-section" x-show="isSectionVisible('shared')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('shared')" @contextmenu.prevent="openSectionMenu($event, 'shared')">
@@ -503,9 +530,10 @@
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},meetings:{visible:true,order:1},
recents:{visible:true,order:2},favorites:{visible:true,order:3},
agents:{visible:true,order:4},shared:{visible:true,order:5},published:{visible:true,order:6}
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
meetings:{visible:true,order:2},recents:{visible:true,order:3},
favorites:{visible:true,order:4},agents:{visible:true,order:5},
shared:{visible:true,order:6},published:{visible:true,order:7}
})" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
@@ -1098,6 +1126,7 @@
window.appState = this;
this.loadSidebarConfig();
this.loadAgents();
this.loadTeamspaces();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1157,6 +1186,17 @@
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
},
teamspaces: [],
loadTeamspaces() {
var self = this;
fetch('/api/v2/wiki/teamspaces').then(function(r){ return r.json(); }).then(function(d){
self.teamspaces = d.teamspaces || [];
}).catch(function(){});
},
openTeamspace(id) {
var target = '/wiki/teamspaces/' + id;
if (window.fdNavigate) window.fdNavigate(target); else window.location = target;
},
sidebarCollapsed: false,
sidebarPeek: false,
mobileSidebarOpen: false,
@@ -1310,7 +1350,7 @@
// ── Sections collapsible ──
sectionsOpen: (function() {
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, teamspaces: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved);
@@ -1361,7 +1401,7 @@
getSectionOrder(key) {
var cfg = this.sidebarConfig[key];
var sections = ['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
var sections = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published'];
if (cfg && typeof cfg.order === 'number') return cfg.order;
return sections.indexOf(key);
},
@@ -1380,7 +1420,7 @@
return (self.sidebarConfig[a] && self.sidebarConfig[a].order || 99) -
(self.sidebarConfig[b] && self.sidebarConfig[b].order || 99);
}) :
['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published'];
var idx = sections.indexOf(key);
if (idx < 0) return;
var swapIdx = direction === 'up' ? idx - 1 : idx + 1;
@@ -1422,7 +1462,7 @@
getSectionIcon(key) {
var icons = {
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
agents: '🤖', shared: '👥', published: '🌐', gitea: '🔗',
agents: '🤖', teamspaces: '🏛️', shared: '👥', published: '🌐', gitea: '🔗',
private: '🔒', library: '📚', 'my-tasks': '✅', marketplace: '🛒',
help: '❓', trash: '🗑️'
};
@@ -1432,7 +1472,7 @@
var labels = {
workspace: 'Workspace', gitea: 'Repository (Gitea)', meetings: 'Meetings',
recents: 'Recents', favorites: 'Favorites', agents: 'Agents',
shared: 'Shared', published: 'Published', private: 'Private',
teamspaces: 'Teamspaces', shared: 'Shared', published: 'Published', private: 'Private',
library: 'Library', 'my-tasks': 'My Tasks', marketplace: 'Marketplace',
help: 'Help', trash: 'Trash'
};
+71 -18
View File
@@ -810,35 +810,57 @@
<!-- Admin: Audit Log -->
<div x-show="activeSection==='admin-audit'">
<h2>Audit Log</h2>
<p class="section-desc">Login history and security events.</p>
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
<template x-for="s in ['all','api','permissions','sso']" :key="s">
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
</template>
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<button class="btn" style="font-size:12px;" @click="loadAuditLogs()">Apply</button>
<span style="flex:1;"></span>
<a class="btn" style="font-size:12px;" :href="auditCsvUrl()" download>Export CSV</a>
</div>
<div class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Source</th>
<th>Actor</th>
<th>Action</th>
<th>Resource</th>
<th>IP</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
<template x-for="e in auditEntries" :key="e.id">
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.logged_at+'Z').toLocaleString()"></span></td>
<td>
<span style="font-weight:500;" x-text="e.full_name || e.login"></span>
<span style="font-size:11px;color:var(--text-dim);display:block;" x-text="e.login"></span>
</td>
<td><code style="font-size:11px;" x-text="e.ip_address || '—'"></code></td>
<td><span style="font-size:11px;color:var(--text-dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" x-text="e.user_agent || '—'"></span></td>
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
<td><code style="font-size:11px;" x-text="e.action"></code></td>
<td><span style="font-size:11px;" x-text="e.resource"></span></td>
<td><code style="font-size:11px;" x-text="e.ip || '—'"></code></td>
<td style="font-size:11px;color:var(--text-dim);max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="e.detail" x-text="e.detail || '—'"></td>
</tr>
</template>
<tr x-show="auditEntries.length === 0">
<td colspan="4" style="text-align:center;color:var(--text-dim);padding:20px;">No login events yet</td>
<tr x-show="!auditLogs.length && !auditLoading">
<td colspan="7" style="text-align:center;color:var(--text-dim);padding:20px;">No audit events yet</td>
</tr>
</tbody>
</table>
</div>
<div style="display:flex;justify-content:center;margin-top:12px;">
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
</div>
<style>
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
.audit-src-api{background:rgba(76,154,255,.15);color:#4c9aff;}
.audit-src-permissions{background:rgba(255,176,0,.15);color:#ffb000;}
.audit-src-sso{background:rgba(0,200,100,.15);color:#00cc66;}
</style>
</div>
<!-- Admin: Backups -->
@@ -1203,6 +1225,7 @@ function settingsInit() {
adminUsers: [],
adminStats: {},
auditEntries: [],
auditLogs: [],
showCreateUser: false,
newUser: {login:'',name:'',email:'',password:'',is_admin:false},
editingUser: null,
@@ -1394,11 +1417,41 @@ function settingsInit() {
},
async loadAdminAudit() {
await this.loadAuditLogs();
},
// ── Unified Audit API (v7.3.0) ──
auditSource: 'all',
auditActor: '',
auditAction: '',
auditOffset: 0,
auditPageSize: 50,
auditLoading: false,
auditHasMore: false,
async loadAuditLogs(reset) {
if (reset !== false) { this.auditOffset = 0; }
this.auditLoading = true;
try {
var r = await this.adminFetch('/api/admin/audit?limit=200');
var d = await r.json();
this.auditEntries = d.entries || [];
} catch(e) { this.auditEntries = []; }
const params = new URLSearchParams({ source: this.auditSource, limit: this.auditPageSize, offset: this.auditOffset });
if (this.auditActor) params.set('actor', this.auditActor);
if (this.auditAction) params.set('action', this.auditAction);
const r = await fetch('/api/v2/audit/logs?' + params.toString());
if (!r.ok) { throw new Error('HTTP ' + r.status); }
const d = await r.json();
const logs = d.logs || [];
this.auditLogs = (reset !== false) ? logs : this.auditLogs.concat(logs);
this.auditHasMore = logs.length >= this.auditPageSize;
} catch(e) {
if (reset !== false) this.auditLogs = [];
this.auditHasMore = false;
}
this.auditLoading = false;
},
auditCsvUrl() {
const params = new URLSearchParams({ source: this.auditSource, limit: 500, format: 'csv' });
if (this.auditActor) params.set('actor', this.auditActor);
if (this.auditAction) params.set('action', this.auditAction);
return '/api/v2/audit/logs?' + params.toString();
},
async adminCreateUser() {