Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069c438aae | ||
|
|
45e59009c3 | ||
|
|
8d0d69e7b8 | ||
|
|
103bc57418 | ||
|
|
45917c194d | ||
|
|
ee1d46e965 | ||
|
|
587ec8d61b | ||
|
|
7a38ddd0f6 | ||
|
|
113374e499 | ||
|
|
0cb476e336 | ||
|
|
2339fa2586 | ||
|
|
8b48dbdd4b | ||
|
|
360c705fd4 | ||
|
|
0698645dbd | ||
|
|
b2e38aece7 | ||
|
|
df9a269d76 | ||
|
|
da7326ffde | ||
|
|
3a1276596c | ||
|
|
07904f05e5 | ||
|
|
224bda74d5 | ||
|
|
c718fe06de | ||
|
|
f706424f90 | ||
|
|
7be96f0618 | ||
|
|
937ecfc2e0 | ||
|
|
998b5c630c | ||
|
|
cb47f5c7f4 | ||
|
|
ffa1fa89ab | ||
|
|
3ad2605c9e | ||
|
|
1f705ce512 | ||
|
|
cf76e00f12 | ||
|
|
0861f1fdbf | ||
|
|
72fcef2ba9 | ||
|
|
5a537f5dc3 | ||
|
|
8ab6569974 | ||
|
|
69a0aceba6 | ||
|
|
d76d7943fc | ||
|
|
d125eb399e | ||
|
|
e6c1f7dbb3 | ||
|
|
465853ac59 | ||
|
|
1706ad1ee9 | ||
|
|
d074689b18 | ||
|
|
9562f30366 | ||
|
|
6dfd6d718e |
@@ -14,3 +14,10 @@ build/
|
||||
node_modules/
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# A9 — jamais de DB ni de fichiers de test dans l'image
|
||||
*.db
|
||||
*.db-*
|
||||
test-commit.md
|
||||
upload_test.txt
|
||||
e2e/
|
||||
|
||||
+26
-2
@@ -24,8 +24,8 @@ LOG_LEVEL=INFO
|
||||
DEFAULT_LANG=fr
|
||||
|
||||
# ── Database ──
|
||||
# SQLite (default): sqlite:////data/flowdeck.db
|
||||
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
|
||||
# SQLite UNIQUEMENT (le code ne lit que le préfixe sqlite:///, tout autre
|
||||
# schéma retombe silencieusement sur /data/flowdeck.db).
|
||||
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
|
||||
# ── Sync ──
|
||||
@@ -58,3 +58,27 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM=FlowDeck <[email protected]>
|
||||
SMTP_USE_TLS=true
|
||||
APP_BASE_URL=http://localhost:8080
|
||||
|
||||
# ── SSO / Enterprise (v6.7.0) ──
|
||||
# Fallback de démarrage uniquement : dès qu'un admin enregistre une configuration
|
||||
# dans Settings → Admin → SSO / Enterprise, la table `sso_config` prime sur le .env.
|
||||
# Le bouton SSO n'apparaît sur la page de connexion que si une config est active.
|
||||
# SSO_PROVIDER=saml # saml | oidc (vide = SSO désactivé)
|
||||
# SSO_NAME=Company SSO # libellé du bouton
|
||||
# SSO_ONLY=false # true = refuser le login local (les admins gardent le leur)
|
||||
# SSO_AUTO_PROVISION=true # créer le compte au premier login SSO
|
||||
# SAML :
|
||||
# SSO_ENTITY_ID=https://idp.example.com/saml/metadata
|
||||
# SSO_SSO_URL=https://idp.example.com/saml/sso
|
||||
# SSO_SLO_URL=https://idp.example.com/saml/slo
|
||||
# SSO_X509_CERTIFICATE=-----BEGIN CERTIFICATE-----
|
||||
# SSO_SIGN_REQUESTS=false # signer les AuthnRequests / LogoutRequest
|
||||
# OIDC :
|
||||
# SSO_ISSUER_URL=https://auth.example.com/realms/flowdeck
|
||||
# SSO_CLIENT_ID=
|
||||
# SSO_CLIENT_SECRET=
|
||||
# SSO_SCOPE=openid profile email
|
||||
# Mapping (JSON) :
|
||||
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
||||
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||
# SSO_DEFAULT_WORKSPACE_ID=0
|
||||
|
||||
@@ -17,3 +17,12 @@ dist/
|
||||
.ua/.trash-*/
|
||||
.ua/.understandignore
|
||||
uv.lock
|
||||
|
||||
# A9 — jamais de DB ni de fichiers de test dans git
|
||||
*.db
|
||||
*.db-*
|
||||
test-commit.md
|
||||
upload_test.txt
|
||||
e2e/node_modules/
|
||||
e2e/shots/
|
||||
e2e/test-results/
|
||||
|
||||
@@ -1709,5 +1709,7 @@ docker compose restart flowdeck
|
||||
- **Base de données avancée** — Relations inter-collections, rollups
|
||||
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||
- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat)
|
||||
- **API agent publique** — `/api/v2/agents/*` + `/api/v2/skills/*` (v6.6.0, agent phase 5) : wrappers Bearer sur `AgentEngine` (run synchrone JSON, journal + rollback, trigger externe) et marketplace de skills (export/import portable, galerie de presets) — `app/routers/api_v2_agent.py`, `app/services/skill_gallery.py`
|
||||
- **SSO / SAML + OIDC entreprise** — v6.7.0 (Enterprise Auth, dernière feature v6.0.0) : SP SAML (`python3-saml`) + OIDC PKCE (`authlib`), auto-provisioning + mapping groupes IdP → rôles workspace, mode « SSO only », onglet admin « SSO / Enterprise », migration 23 (`sso_config`, `sso_login_history`, `sso_requests`), `/help` section SSO — `app/routers/sso.py`, `app/services/sso_provisioning.py`, `app/auth/providers/{saml,oidc}_provider.py`
|
||||
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||
|
||||
+1196
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v6.4.0** — Realtime production (merge 3-voix, broadcast non bloquant), API publique v2, PWA offline
|
||||
> **7.3.9** — audit sécurité P0→P2 (A1–A26, A33–A36, A43 : auth 401 partout, CSRF sans exemption cookie-auth, SSRF, autoescape, logs d'exceptions) · avant : v6.7.x SSO/SAML + OIDC, v6.6.x agent API, v6.5.x synced blocks, v6.4.0 realtime, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,8 +49,10 @@ docker compose up -d
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v6.3)
|
||||
### API & Intégrations (v6.3–v6.6)
|
||||
- **API publique REST v2**: `/api/v2` — CRUD complet, Bearer + scopes `read/write/admin`, pagination, filtres, erreurs RFC 7807, idempotence, audit — [guide](docs/API_GUIDE_V6.md) · OpenAPI `/docs`
|
||||
- **API agent publique (v6.6)**: `/api/v2/agents/*` — agents, conversations, **run synchrone JSON**, journal d'actions + rollback, `trigger` externe
|
||||
- **Marketplace de skills (v6.6)**: export/import portable + galerie de 6 presets installables (`/api/v2/skills/*`), section « Galerie » dans la palette `/` de l'agent
|
||||
- **API publique v1**: `/api/v1` (lecture seule, compat)
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2)
|
||||
- **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot)
|
||||
@@ -87,7 +89,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 725/725 passent
|
||||
python3 -m pytest tests/ -v # 764/764 passent (0 skip)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
+272
-23
File diff suppressed because one or more lines are too long
+15
-3
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues)
|
||||
> **Début**: 2026-07-08 | **Version**: v7.27.0 (audit — A20 phase 2 : CDN retiré, connect-src fermé) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
| v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ |
|
||||
| v6.0–v6.3 | PWA offline, permissions granulaires, web clipper, API publique v2 | ✅ | 668+ |
|
||||
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** |
|
||||
| **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** |
|
||||
| **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** |
|
||||
| **v6.7.0** | **SSO / SAML + OIDC entreprise (Enterprise Auth)** | ✅ | **802** |
|
||||
| v6.8.0 | Sites multi-pages + Forms publics ([doc](docs/V68_Sites_Forms.md)) | ✅ | 20 |
|
||||
| v6.9.0 | Recherche hybride + Ask AI RAG ([doc](docs/V69_Search_Ask_AI.md)) | ✅ | 24 |
|
||||
| v7.0.0 | Automations multi-étapes + Workers lite ([doc](docs/V70_Automations_Workers.md)) | ✅ | 31 |
|
||||
| v7.1.0 | Calendar sync + Meeting Notes ([doc](docs/V71_Calendar_Meetings.md)) | ✅ | 15 |
|
||||
| v7.2.0 | SCIM + 2FA + Audit UI + gouvernance agents ([doc](docs/V72_Enterprise_SCIM_2FA.md)) | ✅ | 52 |
|
||||
| v7.3.0 | Teamspaces + Verified + Polish + follow-ups ([doc](docs/V73_Wiki_Teamspaces_Polish.md)) | ✅ | 72 |
|
||||
|
||||
> **Suites de régression** : `test_v72_enterprise.py` (52) + `test_v73_wiki_polish.py` (72) = **124 verts** · suite complète `-n auto` = **1016 passed**.
|
||||
> **Follow-ups v7.3 livrés** (voir § v7.3.0 du `ROADMAP.md`): sidebar par teamspace, charts `number`/multi-DB, notif `page.updated` aux followers, unfurl `gitea:`/`github:`, page Settings → Audit — + 21 casses SSO corrigées (install `python3-saml`/`authlib`).
|
||||
|
||||
## Blocs Complétés
|
||||
|
||||
@@ -61,5 +73,5 @@ CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/depen
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 749+ tests, TestClient avec SQLite temporaire
|
||||
- **Tests**: pytest, 764+ tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""OIDC provider — authorization code flow with PKCE (v6.7.0).
|
||||
|
||||
Discovery (``.well-known/openid-configuration``) is cached for an hour, the
|
||||
ID token signature is verified against the issuer JWKS via authlib's JOSE
|
||||
implementation, and ``iss`` / ``aud`` / ``exp`` / ``nonce`` are checked here
|
||||
explicitly so the rules are visible and unit-testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Default attribute mapping (design doc §3.2) — OIDC claim names.
|
||||
DEFAULT_OIDC_MAPPING: dict[str, str] = {
|
||||
"login": "sub",
|
||||
"email": "email",
|
||||
"full_name": "name",
|
||||
"avatar_url": "picture",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
_DISCOVERY_TTL = 3600.0
|
||||
_discovery_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""OIDC processing failure — ``message`` is user-facing."""
|
||||
|
||||
|
||||
def pkce_pair() -> tuple[str, str]:
|
||||
"""Return ``(code_verifier, code_challenge)`` for the S256 method."""
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
||||
|
||||
|
||||
async def discover(issuer_url: str) -> dict:
|
||||
"""Fetch (and cache) the issuer's OIDC discovery document."""
|
||||
issuer = issuer_url.rstrip("/")
|
||||
url = f"{issuer}/.well-known/openid-configuration"
|
||||
now = time.time()
|
||||
hit = _discovery_cache.get(issuer)
|
||||
if hit and now - hit[0] < _DISCOVERY_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
doc = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC discovery failed ({url}): {err}") from err
|
||||
if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"):
|
||||
raise OIDCError("OIDC discovery document is missing authorization/token endpoints")
|
||||
_discovery_cache[issuer] = (now, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def build_authorize_url(
|
||||
doc: dict,
|
||||
*,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
nonce: str,
|
||||
code_challenge: str,
|
||||
) -> str:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": scope or "openid profile email",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
sep = "&" if "?" in doc["authorization_endpoint"] else "?"
|
||||
return doc["authorization_endpoint"] + sep + urlencode(params)
|
||||
|
||||
|
||||
async def exchange_code(
|
||||
doc: dict, *, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
|
||||
) -> dict:
|
||||
"""Exchange the authorization code for tokens (PKCE, confidential client)."""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
auth = None
|
||||
if client_secret:
|
||||
auth = (client_id, client_secret)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token request failed: {err}") from err
|
||||
if r.status_code != 200:
|
||||
raise OIDCError(f"OIDC token endpoint returned {r.status_code}: {r.text[:300]}")
|
||||
try:
|
||||
tokens = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token endpoint returned a non-JSON body: {err}") from err
|
||||
if "error" in tokens:
|
||||
raise OIDCError(f"OIDC error: {tokens.get('error')} {tokens.get('error_description', '')}".strip())
|
||||
return tokens
|
||||
|
||||
|
||||
async def fetch_userinfo(doc: dict, access_token: str) -> dict:
|
||||
"""Best-effort userinfo fetch (groups often only live there)."""
|
||||
endpoint = doc.get("userinfo_endpoint")
|
||||
if not endpoint or not access_token:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
data = r.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as err: # userinfo is optional enrichment
|
||||
logger.debug("userinfo fetch failed: %s", err)
|
||||
return {}
|
||||
|
||||
|
||||
def validate_id_token(
|
||||
id_token: str, *, issuer: str, client_id: str, nonce: str, jwks: dict
|
||||
) -> dict:
|
||||
"""Verify the ID token signature and claims. Returns the claims dict."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
from authlib.jose import JsonWebKey
|
||||
from authlib.jose import jwt as jose_jwt
|
||||
|
||||
if isinstance(id_token, bytes):
|
||||
# authlib's jose.jwt.encode() returns bytes; IdP token endpoints send
|
||||
# str — accept both instead of crashing on ``bytes.count(".")``.
|
||||
id_token = id_token.decode()
|
||||
if not id_token or id_token.count(".") != 2:
|
||||
raise OIDCError("Missing or malformed ID token")
|
||||
|
||||
try:
|
||||
keyset = JsonWebKey.import_key_set(jwks)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"Invalid issuer JWKS: {err}") from err
|
||||
|
||||
try:
|
||||
# Pick the key matching the token header (kid) when several are offered.
|
||||
header = json.loads(_b64url_decode(id_token.split(".")[0]))
|
||||
kid = header.get("kid")
|
||||
key = keyset.get_by_kid(kid) if kid and hasattr(keyset, "get_by_kid") else None
|
||||
token_obj = jose_jwt.decode(id_token, key or keyset)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"ID token signature verification failed: {err}") from err
|
||||
|
||||
claims = dict(token_obj) # authlib's JWTClaims is a dict subclass
|
||||
now = int(time.time())
|
||||
|
||||
if claims.get("iss") != issuer.rstrip("/") and claims.get("iss") != issuer:
|
||||
raise OIDCError(f"ID token issuer mismatch: {claims.get('iss')!r}")
|
||||
aud = claims.get("aud")
|
||||
aud_list = aud if isinstance(aud, list) else [aud]
|
||||
if client_id not in aud_list:
|
||||
raise OIDCError("ID token audience does not include this client")
|
||||
exp = claims.get("exp")
|
||||
if not isinstance(exp, int) or exp < now:
|
||||
raise OIDCError("ID token expired")
|
||||
iat = claims.get("iat")
|
||||
if isinstance(iat, int) and iat > now + 300:
|
||||
raise OIDCError("ID token issued in the future")
|
||||
if nonce and claims.get("nonce") != nonce:
|
||||
raise OIDCError("ID token nonce mismatch")
|
||||
if not claims.get("sub"):
|
||||
raise OIDCError("ID token has no subject")
|
||||
return claims
|
||||
|
||||
|
||||
def claims_to_identity(claims: dict, mapping: dict | None = None) -> dict:
|
||||
"""Map OIDC claims onto the shared ``{login, email, full_name, avatar_url, groups}`` shape."""
|
||||
mapping = mapping or DEFAULT_OIDC_MAPPING
|
||||
identity: dict = {"_raw": claims}
|
||||
for field in ("login", "email", "full_name", "avatar_url"):
|
||||
source = mapping.get(field) or field
|
||||
value = claims.get(source, "")
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else ""
|
||||
identity[field] = str(value or "").strip()
|
||||
groups = claims.get(mapping.get("groups", "groups"), [])
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
identity["groups"] = [str(g) for g in groups if g]
|
||||
if not identity["email"]:
|
||||
identity["email"] = claims.get("email", "") or ""
|
||||
if not identity["full_name"]:
|
||||
identity["full_name"] = claims.get("name", "") or identity["email"]
|
||||
return identity
|
||||
@@ -0,0 +1,279 @@
|
||||
"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit).
|
||||
|
||||
v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data``
|
||||
dict and builds the SP settings from the ``sso_config`` row.
|
||||
|
||||
What the toolkit validates in strict mode (all covered by tests):
|
||||
XML schema, signature of the assertion and/or the message against the IdP
|
||||
certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``,
|
||||
``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo``
|
||||
against the AuthnRequest id we pass to ``process_response()`` — combined
|
||||
with the single-use ``sso_requests`` store that makes replay impossible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
|
||||
#: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's
|
||||
#: NameID; every other value is matched against attribute Name / FriendlyName
|
||||
#: / URI local part (so ``email`` finds both ``email`` and
|
||||
#: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``).
|
||||
DEFAULT_SAML_MAPPING: dict[str, str] = {
|
||||
"login": "nameid",
|
||||
"email": "nameid",
|
||||
"full_name": "displayName",
|
||||
"avatar_url": "avatar",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
|
||||
class SAMLError(Exception):
|
||||
"""SAML processing failure — ``message`` is user-facing, ``reason`` is logged."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLIdentity:
|
||||
"""What a validated assertion tells us about the user."""
|
||||
|
||||
name_id: str
|
||||
name_id_format: str = ""
|
||||
session_index: str = ""
|
||||
attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
friendly_attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
def resolve(self, source: str) -> str:
|
||||
"""Resolve one mapped source (``nameid`` or an attribute name) → first value."""
|
||||
if not source or source == "nameid":
|
||||
return self.name_id or ""
|
||||
if source in self.attributes and self.attributes[source]:
|
||||
return (self.attributes[source][0] or "").strip()
|
||||
# FriendlyName match (case-insensitive)
|
||||
lower = {k.lower(): v for k, v in self.friendly_attributes.items()}
|
||||
if source.lower() in lower and lower[source.lower()]:
|
||||
return (lower[source.lower()][0] or "").strip()
|
||||
# URI local part match: ".../claims/emailaddress" ~ "emailaddress", and
|
||||
# a mapping of "email" must still find ".../claims/emailaddress".
|
||||
want = source.lower().lstrip("./")
|
||||
for name, values in self.attributes.items():
|
||||
if not values:
|
||||
continue
|
||||
local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower()
|
||||
if local == want or local.endswith(want) or want.endswith(local):
|
||||
return (values[0] or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def external_base_url(request: Request) -> str:
|
||||
"""Scheme://host the user actually used (proxy-aware, like OAuth redirects)."""
|
||||
proto = request.headers.get("x-forwarded-proto", "")
|
||||
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
|
||||
fwd_host = request.headers.get("x-forwarded-host", "")
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def saml_endpoints(request: Request) -> dict[str, str]:
|
||||
"""SP entity id + ACS/SLO/metadata URLs derived from the incoming request."""
|
||||
base = external_base_url(request)
|
||||
return {
|
||||
"entity_id": f"{base}/auth/saml/metadata",
|
||||
"acs": f"{base}/auth/saml/callback",
|
||||
"slo": f"{base}/auth/saml/logout",
|
||||
"metadata": f"{base}/auth/saml/metadata",
|
||||
}
|
||||
|
||||
|
||||
def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict:
|
||||
"""python3-saml settings dict built from a ``sso_config`` row."""
|
||||
sign_requests = bool(cfg.get("sign_requests"))
|
||||
sp: dict = {
|
||||
"entityId": endpoints["entity_id"],
|
||||
"assertionConsumerService": {
|
||||
"url": endpoints["acs"],
|
||||
"binding": BINDING_HTTP_POST,
|
||||
},
|
||||
"singleLogoutService": {
|
||||
"url": endpoints["slo"],
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"NameIDFormat": NAMEID_FORMAT_EMAIL,
|
||||
}
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
sp["privateKey"] = cfg["sp_private_key"]
|
||||
sp["x509cert"] = cfg["sp_certificate"]
|
||||
|
||||
idp: dict = {
|
||||
"entityId": cfg.get("entity_id") or "",
|
||||
"singleSignOnService": {
|
||||
"url": cfg.get("sso_url") or "",
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"x509cert": cfg.get("x509_certificate") or "",
|
||||
}
|
||||
if cfg.get("slo_url"):
|
||||
idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT}
|
||||
|
||||
return {
|
||||
"strict": True,
|
||||
"debug": False,
|
||||
"sp": sp,
|
||||
"idp": idp,
|
||||
"security": {
|
||||
"authnRequestsSigned": sign_requests,
|
||||
"logoutRequestSigned": sign_requests,
|
||||
"logoutResponseSigned": False,
|
||||
"wantMessagesSigned": False,
|
||||
"wantAssertionsSigned": True,
|
||||
"wantNameIdEncrypted": False,
|
||||
"wantAssertionsEncrypted": False,
|
||||
"wantXmlValidation": True,
|
||||
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
||||
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
|
||||
"rejectDeprecatedAlgorithm": True,
|
||||
# FlowDeck is self-hosted: LAN/homelab deploys commonly reach the
|
||||
# SP through single-label hosts (http://flowdeck/, docker service
|
||||
# names). python3-saml rejects those URLs unless this is on.
|
||||
"allowSingleLabelDomains": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict:
|
||||
"""Flat request dict expected by ``OneLogin_Saml2_Auth``."""
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
return {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": script_name,
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(request.query_params),
|
||||
"post_data": post_data or {},
|
||||
}
|
||||
|
||||
|
||||
def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None):
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
try:
|
||||
return OneLogin_Saml2_Auth(
|
||||
_request_data(request, script_name, post_data=post_data), old_settings=settings
|
||||
)
|
||||
except Exception as err: # malformed IdP/SP config (bad cert, missing URL…)
|
||||
raise SAMLError(f"Invalid SAML configuration: {err}") from err
|
||||
|
||||
|
||||
def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]:
|
||||
"""Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``."""
|
||||
auth = _auth(request, cfg, "/auth/saml/login")
|
||||
try:
|
||||
url = auth.login(return_to=relay_state)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err
|
||||
request_id = auth.get_last_request_id() or ""
|
||||
if not request_id:
|
||||
raise SAMLError("AuthnRequest was built without an id")
|
||||
return url, request_id
|
||||
|
||||
|
||||
def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity:
|
||||
"""Validate the IdP's SAMLResponse and extract the identity.
|
||||
|
||||
``request_id`` is the id of the AuthnRequest we issued (from the
|
||||
single-use ``sso_requests`` row): the toolkit rejects any response whose
|
||||
``InResponseTo`` does not match it.
|
||||
"""
|
||||
auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data)
|
||||
try:
|
||||
auth.process_response(request_id=request_id or None)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML response could not be processed: {err}") from err
|
||||
|
||||
errors = auth.get_errors()
|
||||
if errors:
|
||||
raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}")
|
||||
if not auth.is_authenticated():
|
||||
raise SAMLError("SAML response did not authenticate the user")
|
||||
|
||||
name_id = auth.get_nameid() or ""
|
||||
if not name_id:
|
||||
raise SAMLError("SAML assertion carries no NameID")
|
||||
return SAMLIdentity(
|
||||
name_id=name_id,
|
||||
name_id_format=auth.get_nameid_format() or "",
|
||||
session_index=auth.get_session_index() or "",
|
||||
attributes=auth.get_attributes() or {},
|
||||
friendly_attributes=auth.get_friendlyname_attributes() or {},
|
||||
)
|
||||
|
||||
|
||||
def metadata_xml(request: Request, cfg: dict) -> str:
|
||||
"""SP metadata XML (for the IdP configuration screen)."""
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
|
||||
settings = OneLogin_Saml2_Settings(
|
||||
build_settings(cfg, saml_endpoints(request)), custom_base_path=None
|
||||
)
|
||||
try:
|
||||
xml = settings.get_sp_metadata()
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SP metadata: {err}") from err
|
||||
if isinstance(xml, bytes):
|
||||
xml = xml.decode("utf-8")
|
||||
return xml
|
||||
|
||||
|
||||
def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str:
|
||||
"""SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP)."""
|
||||
auth = _auth(request, cfg, "/auth/saml/logout")
|
||||
if not cfg.get("slo_url"):
|
||||
raise SAMLError("The IdP has no Single Logout URL configured")
|
||||
try:
|
||||
return auth.logout(
|
||||
return_to=return_to,
|
||||
name_id=name_id or None,
|
||||
session_index=session_index or None,
|
||||
)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err
|
||||
|
||||
|
||||
def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]:
|
||||
"""Process a LogoutRequest / LogoutResponse received from the IdP.
|
||||
|
||||
``form`` holds the POSTed fields, ``query`` the GET parameters (the
|
||||
HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there).
|
||||
Returns ``(redirect_url, errors)``.
|
||||
"""
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")}
|
||||
if not post_data:
|
||||
post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {}
|
||||
req_data = {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": "/auth/saml/logout",
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(query),
|
||||
"post_data": post_data,
|
||||
}
|
||||
auth = OneLogin_Saml2_Auth(req_data, old_settings=settings)
|
||||
try:
|
||||
url = auth.process_slo(keep_local_session=True)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML logout could not be processed: {err}") from err
|
||||
return url, auth.get_errors()
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
@@ -31,7 +31,7 @@ class SessionManager:
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
@@ -94,7 +94,7 @@ class SessionManager:
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
if user_id:
|
||||
@@ -171,11 +171,11 @@ def _touch_session(sid: str) -> None:
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_touch_session")
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
async def get_current_user(request) -> dict | None:
|
||||
def get_current_user(request) -> dict | None:
|
||||
"""FastAPI dependency: extract current user from session cookie."""
|
||||
session = request.cookies.get("flowdeck_session")
|
||||
if session:
|
||||
|
||||
+33
-4
@@ -1,12 +1,22 @@
|
||||
"""FlowDeck — Configuration via pydantic-settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@property
|
||||
def data_dir(self) -> str:
|
||||
"""Racine des fichiers (avatars, uploads…).
|
||||
|
||||
Pas un champ : la lecture est faite à chaque accès parce que les tests
|
||||
monkeypatchent `FLOWDECK_DATA_DIR` en cours de vie (A42 — les 9 copies
|
||||
de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` vont ici).
|
||||
"""
|
||||
return os.environ.get("FLOWDECK_DATA_DIR", "/data")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||
)
|
||||
@@ -22,9 +32,6 @@ class Settings(BaseSettings):
|
||||
github_oauth_client_id: str = ""
|
||||
github_oauth_client_secret: str = ""
|
||||
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
|
||||
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
|
||||
oauth_redirect_uri: str = ""
|
||||
@@ -82,6 +89,26 @@ class Settings(BaseSettings):
|
||||
smtp_use_tls: bool = True
|
||||
app_base_url: str = "http://localhost:8080"
|
||||
|
||||
# SSO / SAML + OIDC (v6.7.0) — bootstrap fallback ONLY: as soon as an admin
|
||||
# saves a configuration in Settings → Admin → SSO / Enterprise, the
|
||||
# `sso_config` table wins (see app/services/sso_provisioning.py).
|
||||
sso_provider: str = "" # 'saml' | 'oidc' | '' (disabled)
|
||||
sso_name: str = "Company SSO" # button label on the login page
|
||||
sso_entity_id: str = "" # SAML: IdP entity id
|
||||
sso_sso_url: str = "" # SAML: IdP SSO URL (HTTP-Redirect)
|
||||
sso_slo_url: str = "" # SAML: IdP Single Logout URL
|
||||
sso_x509_certificate: str = "" # SAML: IdP signing certificate (PEM)
|
||||
sso_issuer_url: str = "" # OIDC: issuer identifier
|
||||
sso_client_id: str = "" # OIDC: client id
|
||||
sso_client_secret: str = "" # OIDC: client secret (env only)
|
||||
sso_scope: str = "openid profile email"
|
||||
sso_attribute_mapping: str = "" # JSON, defaults per provider
|
||||
sso_groups_mapping: str = "[]" # JSON [{sso_group, workspace_role, workspace_id}]
|
||||
sso_auto_provision: bool = True
|
||||
sso_only: bool = False # refuse local login when true
|
||||
sso_sign_requests: bool = False # sign AuthnRequest / LogoutRequest
|
||||
sso_default_workspace_id: int = 0
|
||||
|
||||
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
|
||||
# (deterministic rule-based planner so the agent works without any API key).
|
||||
agent_enabled: bool = True
|
||||
@@ -109,7 +136,9 @@ class Settings(BaseSettings):
|
||||
import re
|
||||
if re.match(r'^[a-zA-Z]:', p):
|
||||
return Path(p)
|
||||
return Path("/" + p)
|
||||
# A26 : `sqlite:////data/flowdeck.db` donne p='/data/…' — un simple
|
||||
# concat '/'+'/' → '//data/…' = chemin UNC sous Windows. On normalise.
|
||||
return Path("/" + p.lstrip("/"))
|
||||
return Path("/data/flowdeck.db")
|
||||
|
||||
|
||||
|
||||
@@ -837,6 +837,11 @@ def get_conn():
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
# A21 (partiel) : un seul writer par DB — sans timeout la requête concurrente
|
||||
# échoue immédiatement avec « database is locked » (tests xdist, schedulers).
|
||||
# ponytail: le reste d'A21 (wrapper async + 510 call sites synchrones sur
|
||||
# l'event loop) reste à migrer module par module.
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
|
||||
+130
-25
@@ -39,18 +39,29 @@ from app.routers import (
|
||||
workspace,
|
||||
)
|
||||
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(
|
||||
@@ -60,55 +71,107 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _spawn(name: str, factory):
|
||||
"""A34 : une tâche scheduler meurt en silence (aucun done_callback).
|
||||
|
||||
Loggue l'exception puis recrée la coroutine 10 s plus tard.
|
||||
ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque
|
||||
tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si
|
||||
le bruit devient un problème.
|
||||
"""
|
||||
|
||||
async def _guard():
|
||||
while True:
|
||||
try:
|
||||
await factory()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("scheduler %s plante - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
else:
|
||||
logger.warning("scheduler %s termine - redemarrage dans 10 s", name)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
return asyncio.create_task(_guard())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
init_db()
|
||||
init_webhook_tables()
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
admin_hash = hash_password("FlowDeck2026!")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(admin_hash,)
|
||||
|
||||
# A26 : secret de session par défaut refusé (il signe `flowdeck_session`).
|
||||
if settings.app_secret_key == "change-me-to-random":
|
||||
raise RuntimeError(
|
||||
"APP_SECRET_KEY non défini — générer une valeur : "
|
||||
'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env'
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au
|
||||
# premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent.
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM users WHERE login='admin'").fetchone():
|
||||
admin_pw = os.environ.get("FLOWDECK_ADMIN_PASSWORD") or secrets.token_urlsafe(12)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(hash_password(admin_pw),),
|
||||
)
|
||||
conn.commit()
|
||||
logger.warning(
|
||||
"Premier démarrage : compte admin créé, mot de passe = %s "
|
||||
"(définir FLOWDECK_ADMIN_PASSWORD pour le fixer)",
|
||||
admin_pw,
|
||||
)
|
||||
|
||||
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
|
||||
from app.routers.agent import agent_scheduler
|
||||
scheduler_task = asyncio.create_task(agent_scheduler())
|
||||
scheduler_task = _spawn("agent_scheduler", agent_scheduler)
|
||||
|
||||
# ── Automations (v5.1.0): cron trigger scheduler ──
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
automation_task = _spawn("automation_scheduler", automation_scheduler)
|
||||
|
||||
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
|
||||
from app.services.backup import backup_scheduler
|
||||
backup_task = asyncio.create_task(backup_scheduler())
|
||||
backup_task = _spawn("backup_scheduler", backup_scheduler)
|
||||
|
||||
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
|
||||
from app.services.projects import project_sync_scheduler
|
||||
projects_task = asyncio.create_task(project_sync_scheduler())
|
||||
projects_task = _spawn("project_sync_scheduler", project_sync_scheduler)
|
||||
|
||||
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
|
||||
from app.services.trash import trash_purge_scheduler
|
||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
||||
trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler)
|
||||
|
||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
|
||||
|
||||
# ── Calendar sync (v7.1.0): external calendars every 15 min ──
|
||||
from app.services.calendar_sync import calendar_sync_scheduler
|
||||
calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler)
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||
webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler)
|
||||
|
||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||
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:
|
||||
@@ -122,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.5.0",
|
||||
version="7.27.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -132,9 +195,25 @@ app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_ag
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
app.add_middleware(ContentSecurityPolicyMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
# A37 : origines explicites (l'auth est un cookie de session ; le front est
|
||||
# servi par le même hôte). `*` + credentials est la combinaison interdite par la
|
||||
# spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées.
|
||||
_CORS_ORIGINS = sorted(
|
||||
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
|
||||
)
|
||||
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
|
||||
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_CORS_ORIGINS,
|
||||
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*",
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(sso_router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(board.router)
|
||||
app.include_router(notes.router)
|
||||
@@ -169,19 +248,31 @@ app.include_router(permissions_router)
|
||||
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")
|
||||
|
||||
|
||||
@app.get("/manifest.json")
|
||||
async def pwa_manifest():
|
||||
def pwa_manifest():
|
||||
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/manifest.json", media_type="application/manifest+json")
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def service_worker():
|
||||
def service_worker():
|
||||
"""Serve the PWA service worker at top-level scope (/)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/sw.js", media_type="application/javascript")
|
||||
@@ -191,7 +282,7 @@ async def service_worker():
|
||||
|
||||
|
||||
@app.get("/api/csrf-token")
|
||||
async def csrf_token_endpoint(request: Request):
|
||||
def csrf_token_endpoint(request: Request):
|
||||
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
|
||||
import secrets
|
||||
|
||||
@@ -206,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
|
||||
|
||||
|
||||
@app.get("/api/pages")
|
||||
async def api_pages_alias(request: Request):
|
||||
def api_pages_alias(request: Request):
|
||||
"""Alias /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
qs = str(request.url.query)
|
||||
@@ -215,7 +306,7 @@ async def api_pages_alias(request: Request):
|
||||
|
||||
|
||||
@app.post("/api/pages")
|
||||
async def api_pages_post_alias(request: Request):
|
||||
def api_pages_post_alias(request: Request):
|
||||
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/board/api/pages", status_code=307)
|
||||
@@ -250,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
|
||||
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
|
||||
|
||||
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||
@@ -258,17 +349,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
|
||||
|
||||
+17
-1
@@ -16,7 +16,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/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.
|
||||
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
|
||||
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
|
||||
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
|
||||
# library, gitea_workspace, workspace, workspaces, welcome).
|
||||
# Ne restent que du machine-to-machine / hors session :
|
||||
# - Bearer : /api/webhook, /api/v1, /api/v2, /scim/v2
|
||||
# - callbacks : /auth/* (login, register, SSO, 2FA, WebAuthn)
|
||||
# - publics : /s/ (sites), /f/ (forms)
|
||||
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
|
||||
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",
|
||||
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
+95
-11
@@ -1,6 +1,9 @@
|
||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -8,6 +11,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────
|
||||
|
||||
# Allowed extensions for file uploads
|
||||
@@ -62,13 +67,32 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
CSP_HEADER = "Content-Security-Policy"
|
||||
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
# ponytail: `https:` reste ouvert — unfurls (YouTube/Vimeo/…) et
|
||||
# tuiles OSM sont inénumérables ; plafond assumé, à resserrer si
|
||||
# un proxy d'images local arrive.
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"font-src 'self' data:; "
|
||||
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
|
||||
# côté front — grep négatif) et websockets scopés à l'hôte de la
|
||||
# requête ({host}) → plus de canal d'exfil vers un tiers.
|
||||
"connect-src 'self' ws://{host} wss://{host}; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
@@ -77,11 +101,22 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
|
||||
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
|
||||
# exactement ce que les templates liront via `csp_nonce()`.
|
||||
CSP_NONCE.set(nonce)
|
||||
response = await call_next(request)
|
||||
# Only set CSP on HTML responses
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "text/html" in content_type:
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE
|
||||
# Host du navigateur (uvicorn rejette les Host invalides) ;
|
||||
# on retire quand même tout caractère hors base URL par sécurité.
|
||||
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
|
||||
request.headers.get("host", ""))
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
||||
nonce=nonce, host=host
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -97,8 +132,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Paths that should be rate-limited
|
||||
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||
"/api/", "/board/api/", "/auth/",
|
||||
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
|
||||
# API workspace + collections (les endpoints mutants du legacy).
|
||||
"/scim/v2/", "/workspace/", "/db/",
|
||||
)
|
||||
|
||||
# Pages publiques : seul le non-GET est plafonné (brute force de
|
||||
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
|
||||
# visiteurs d'un site publié qui partagent une IP.
|
||||
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
|
||||
|
||||
# Paths exempt from rate limiting even under an API prefix
|
||||
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||
"/api/health",
|
||||
@@ -106,11 +149,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"/api/frontend-errors",
|
||||
})
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
|
||||
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||
self._last_prune = 0.0
|
||||
self._max_keys = 5000
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
@@ -120,27 +167,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
if not settings.rate_limit_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Only rate-limit API routes
|
||||
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
||||
# Only rate-limit API routes (+ non-GET sur les pages publiques)
|
||||
method = request.method.upper()
|
||||
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
|
||||
method not in ("GET", "HEAD", "OPTIONS")
|
||||
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
|
||||
)
|
||||
if not limited:
|
||||
return await call_next(request)
|
||||
|
||||
# Exempt health check and error capture
|
||||
if path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
limit = self.max_requests or settings.rate_limit_requests
|
||||
ip = self._client_key(request)
|
||||
now = time.time()
|
||||
|
||||
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
|
||||
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
|
||||
self._prune(now)
|
||||
|
||||
window_start, count = self._store[ip]
|
||||
if now - window_start > self.window_seconds:
|
||||
self._store[ip] = (now, 1)
|
||||
return await call_next(request)
|
||||
|
||||
if count >= self.max_requests:
|
||||
if count >= limit:
|
||||
return JSONResponse(
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
self._store[ip] = (window_start, count + 1)
|
||||
return await call_next(request)
|
||||
|
||||
def _client_key(self, request: Request) -> str:
|
||||
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
|
||||
|
||||
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
|
||||
globales, pas seulement RFC1918) — un pair non-global n'est pas un
|
||||
internaute, donc le XFF du proxy fait foi.
|
||||
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
|
||||
exposée directement), prendre la dernière adresse non privée de la
|
||||
chaîne plutôt que la première.
|
||||
"""
|
||||
host = request.client.host if request.client else "unknown"
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
try:
|
||||
direct = ipaddress.ip_address(host)
|
||||
if direct.is_private or direct.is_loopback:
|
||||
return fwd.split(",")[0].strip() or host
|
||||
except ValueError:
|
||||
pass # hôte non-IP (testserver…) → on garde la clé d'origine
|
||||
return host
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
|
||||
for k in expired:
|
||||
del self._store[k]
|
||||
self._last_prune = now
|
||||
|
||||
+592
-22
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
|
||||
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
|
||||
|
||||
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
|
||||
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
|
||||
unitaire se lit déjà dans le set.
|
||||
"""
|
||||
if not table.replace("_", "").isalnum():
|
||||
raise ValueError(f"nom de table invalide: {table!r}")
|
||||
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
_ensure_table(conn)
|
||||
row = conn.execute(
|
||||
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
|
||||
for version, name, fn in MIGRATIONS:
|
||||
if version <= applied:
|
||||
continue
|
||||
_apply_one(conn, version, name, fn)
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
|
||||
"""A31 : une migration = une transaction (DDL tout-ou-rien).
|
||||
|
||||
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
|
||||
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
|
||||
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
|
||||
rollback complet à l'échec, donc la prochaine exécution retente proprement.
|
||||
"""
|
||||
if conn.in_transaction:
|
||||
# transaction résiduelle du caller (init_db commit juste avant) — on
|
||||
# part d'un état propre plutôt que d'englober son travail.
|
||||
conn.commit()
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(version, name),
|
||||
)
|
||||
conn.commit()
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
``projects`` — normalized project list across forges (builtin/gitea/
|
||||
github) + last sync timestamp for the periodic cron.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
_pcols = columns(conn, "api_tokens")
|
||||
if "id" not in _pcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
|
||||
)
|
||||
|
||||
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
|
||||
_scols = columns(conn, "user_sessions")
|
||||
if "id" not in _scols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
|
||||
)
|
||||
|
||||
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
_projcols = columns(conn, "projects")
|
||||
if "id" not in _projcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
|
||||
)
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "cover_url" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
if "page_icon" not in _pcols:
|
||||
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
and the built-in database templates are seeded (idempotently)."""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
|
||||
_cols = columns(conn, "database_templates")
|
||||
if "icon" not in _cols:
|
||||
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "validation_json" not in _pcols:
|
||||
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
||||
|
||||
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
|
||||
``collection_pages.cover_url`` — per-row cover image (gallery/board
|
||||
cards), independent from the block-page ``pages.cover_url``.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "group_name" not in _pcols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
|
||||
_vcols = columns(conn, "collection_views")
|
||||
if "created_by" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
||||
if "updated_at" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
|
||||
|
||||
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
_cpcols = columns(conn, "collection_pages")
|
||||
if "cover_url" not in _cpcols:
|
||||
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
|
||||
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
|
||||
)
|
||||
|
||||
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
_ucols = columns(conn, "users")
|
||||
if "timezone" not in _ucols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
|
||||
``page_global_templates`` — user-created global page templates
|
||||
(blocks_json = same format as the block editor saves).
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "is_locked" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||||
if "locked_by" not in _pcols:
|
||||
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
_ccols = columns(conn, "collections")
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "sync_version" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
_cols = columns(conn, "api_tokens")
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
@@ -862,7 +895,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_migration_v630_api_v2")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||
``superseded`` (a retry row replaced this attempt).
|
||||
"""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||
_cols = columns(conn, "webhook_deliveries")
|
||||
if "event" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||
if "next_retry_at" not in _cols:
|
||||
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
cols = columns(conn, "pages")
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
@@ -983,3 +1016,540 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_row "
|
||||
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
@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 = columns(conn, "collections")
|
||||
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 = columns(conn, "pages")
|
||||
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 = columns(conn, "automations")
|
||||
if "trigger_mode" not in auto_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
|
||||
)
|
||||
prop_cols = columns(conn, "collection_properties")
|
||||
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 = columns(conn, "collection_pages")
|
||||
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 = columns(conn, "users")
|
||||
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 = columns(conn, table)
|
||||
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.
|
||||
|
||||
``sso_config`` — single active SSO provider (SAML or OIDC), managed
|
||||
from Settings → Admin → SSO / Enterprise. Secrets
|
||||
(``client_secret``, SP private key) are encrypted at
|
||||
rest by ``app.services.sso_provisioning``.
|
||||
``sso_login_history`` — audit trail of every SSO login attempt (successes
|
||||
AND rejections — signature failure, replay, no
|
||||
local account…).
|
||||
``sso_requests`` — single-use anti-replay store: AuthnRequest ids and
|
||||
OIDC states, CSRF relay tokens, PKCE verifiers and
|
||||
the post-login redirect target. One row is consumed
|
||||
by exactly one callback.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL DEFAULT 'saml',
|
||||
name TEXT NOT NULL DEFAULT 'Company SSO',
|
||||
entity_id TEXT NOT NULL DEFAULT '',
|
||||
sso_url TEXT NOT NULL DEFAULT '',
|
||||
slo_url TEXT DEFAULT '',
|
||||
x509_certificate TEXT NOT NULL DEFAULT '',
|
||||
issuer_url TEXT DEFAULT '',
|
||||
client_id TEXT DEFAULT '',
|
||||
client_secret TEXT DEFAULT '',
|
||||
scope TEXT DEFAULT 'openid profile email',
|
||||
attribute_mapping TEXT NOT NULL DEFAULT '{}',
|
||||
groups_mapping TEXT NOT NULL DEFAULT '[]',
|
||||
auto_provision INTEGER NOT NULL DEFAULT 1,
|
||||
sso_only INTEGER NOT NULL DEFAULT 0,
|
||||
sign_requests INTEGER NOT NULL DEFAULT 0,
|
||||
default_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
sp_private_key TEXT DEFAULT '',
|
||||
sp_certificate TEXT DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL DEFAULT 'SSO',
|
||||
sso_identifier TEXT,
|
||||
ip_address TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_history_user "
|
||||
"ON sso_login_history(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sso_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
relay_state TEXT NOT NULL DEFAULT '',
|
||||
code_verifier TEXT NOT NULL DEFAULT '',
|
||||
next_path TEXT NOT NULL DEFAULT '/workspaces',
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
||||
)
|
||||
|
||||
+1
-18
@@ -1,10 +1,9 @@
|
||||
"""FlowDeck — Pydantic request models for API validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import UploadFile
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
|
||||
from app.middleware.security import ALLOWED_EXTENSIONS, _ext
|
||||
|
||||
# ── File Save ────────────────────────────────────────────────
|
||||
|
||||
@@ -34,23 +33,7 @@ class UploadValidationResult(BaseModel):
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def validate_upload_request(file: UploadFile) -> str | None:
|
||||
"""Validate an uploaded file (size + extension). Returns error message or None."""
|
||||
# Size check — we can't read the full file without a size attribute,
|
||||
# but Starlette's UploadFile has a size property from Content-Length
|
||||
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
|
||||
return f"File '{file.filename}' exceeds maximum size of 10 MB"
|
||||
|
||||
# Extension check
|
||||
if file.filename:
|
||||
ext = _ext(file.filename)
|
||||
if ext and ext not in ALLOWED_EXTENSIONS:
|
||||
return f"File extension '{ext}' is not allowed"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Issue Create / Update ────────────────────────────────────
|
||||
|
||||
class IssueCreateRequest(BaseModel):
|
||||
"""Request model for creating a Gitea issue."""
|
||||
|
||||
+8
-16
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
@@ -8,7 +8,7 @@ router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
# ── Dependency ──
|
||||
async def admin_required(request: Request):
|
||||
from app.auth.session import get_current_user
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
|
||||
|
||||
# ── Users ──
|
||||
@router.get("/users")
|
||||
async def list_users(_admin=Depends(admin_required)):
|
||||
def list_users(_admin=Depends(admin_required)):
|
||||
"""List all users with workspace/file/folder counts and storage usage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -46,15 +46,11 @@ async def list_users(_admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Create a new user (admin only)."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = body.get("login", "").strip()
|
||||
name = body.get("name", login)
|
||||
email = body.get("email", login)
|
||||
@@ -78,15 +74,11 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.put("/users/{user_id:int}")
|
||||
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
|
||||
def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Update a user: name, email, password, admin status, active status."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
@@ -109,7 +101,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
|
||||
|
||||
|
||||
@router.delete("/users/{user_id:int}")
|
||||
async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -139,7 +131,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
|
||||
# ── Stats ──
|
||||
@router.get("/stats")
|
||||
async def user_stats(_admin=Depends(admin_required)):
|
||||
def user_stats(_admin=Depends(admin_required)):
|
||||
"""Aggregate stats: total users, workspaces, files, storage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -159,7 +151,7 @@ async def user_stats(_admin=Depends(admin_required)):
|
||||
|
||||
# ── Audit ──
|
||||
@router.get("/audit")
|
||||
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
"""Recent login history."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
|
||||
+176
-90
@@ -7,13 +7,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.session import get_current_user
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||
from app.services.llm_config import (
|
||||
@@ -50,7 +52,7 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
triggers = conn.execute(
|
||||
"SELECT * FROM agent_triggers WHERE trigger_type='schedule' AND is_active=1"
|
||||
).fetchall()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
for trig in triggers:
|
||||
last = trig["last_fired_at"]
|
||||
if last:
|
||||
@@ -91,17 +93,16 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
logger.exception("Agent scheduler tick failed")
|
||||
|
||||
|
||||
async def _current_user_id(request: Request) -> int | None:
|
||||
user = await get_current_user(request)
|
||||
if user and user.get("id"):
|
||||
return user["id"]
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
return row["id"] if row else None
|
||||
def _current_user_id(request: Request) -> int:
|
||||
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
|
||||
user = get_current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
async def _workspace_id(request: Request) -> int | None:
|
||||
user = await get_current_user(request)
|
||||
def _workspace_id(request: Request) -> int | None:
|
||||
user = get_current_user(request)
|
||||
if user and user.get("workspace_id"):
|
||||
return user["workspace_id"]
|
||||
try:
|
||||
@@ -111,23 +112,20 @@ async def _workspace_id(request: Request) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _current_admin(request: Request) -> dict:
|
||||
"""Require an admin session. Falls back to the single admin row, matching
|
||||
the agent router's unauthenticated convention (single-user deployments)."""
|
||||
user = await get_current_user(request)
|
||||
if user:
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE login='admin' ORDER BY id LIMIT 1").fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return dict(row)
|
||||
def _current_admin(request: Request) -> dict:
|
||||
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
|
||||
`ping()` vers un `api_base` de son choix = SSRF)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn as _gc
|
||||
with _gc() as conn:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
return user
|
||||
|
||||
|
||||
def _default_agent(conn, user_id: int) -> dict:
|
||||
@@ -148,9 +146,9 @@ def _default_agent(conn, user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agents(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
def list_agents(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user_id)
|
||||
rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall()
|
||||
@@ -158,10 +156,9 @@ async def list_agents(request: Request):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_agent(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_agent(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
@@ -186,8 +183,8 @@ async def create_agent(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
def list_conversations(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC",
|
||||
@@ -197,10 +194,9 @@ async def list_conversations(request: Request):
|
||||
|
||||
|
||||
@router.post("/conversations")
|
||||
async def create_conversation(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
ws = await _workspace_id(request)
|
||||
def create_conversation(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = _default_agent(conn, user_id)
|
||||
cur = conn.execute(
|
||||
@@ -215,7 +211,7 @@ async def create_conversation(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
async def get_conversation(request: Request, conversation_id: int):
|
||||
def get_conversation(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
|
||||
if not conv:
|
||||
@@ -228,7 +224,7 @@ async def get_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
async def delete_conversation(request: Request, conversation_id: int):
|
||||
def delete_conversation(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
||||
@@ -238,10 +234,9 @@ async def delete_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}")
|
||||
async def patch_conversation(request: Request, conversation_id: int):
|
||||
def patch_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
"""Update a conversation's title / provider / model (slash-command support)."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conv = conn.execute(
|
||||
"SELECT id FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
@@ -264,10 +259,9 @@ async def patch_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/run")
|
||||
async def run_conversation(request: Request, conversation_id: int):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
async def run_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
objective = (body.get("message") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(status_code=400, detail="message est requis")
|
||||
@@ -325,7 +319,7 @@ async def agent_generate(request: Request):
|
||||
Because no tool schema is offered, the model answers with plain text based on
|
||||
the provided document context instead of issuing search_workspace / tools.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prompt = (body.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
@@ -385,7 +379,7 @@ async def agent_writing(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import WRITING_ACTIONS, AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
action = (body.get("action") or "").strip().lower()
|
||||
if not action:
|
||||
@@ -424,7 +418,7 @@ async def agent_writing_properties(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
properties = body.get("properties") or []
|
||||
if not isinstance(properties, list) or not properties:
|
||||
@@ -452,7 +446,7 @@ async def agent_writing_properties(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/actions")
|
||||
async def list_actions(request: Request, conversation_id: int):
|
||||
def list_actions(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
@@ -462,7 +456,7 @@ async def list_actions(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.post("/actions/{action_id}/undo")
|
||||
async def undo(request: Request, action_id: int):
|
||||
def undo(request: Request, action_id: int):
|
||||
try:
|
||||
undo_action(action_id)
|
||||
except ValueError as exc:
|
||||
@@ -476,18 +470,17 @@ async def undo(request: Request, action_id: int):
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills(request: Request):
|
||||
ws = await _workspace_id(request)
|
||||
def list_skills(request: Request):
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall()
|
||||
return {"skills": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_skill(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name est requis")
|
||||
@@ -506,10 +499,10 @@ async def create_skill(request: Request):
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill(request: Request, skill_id: int):
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
@@ -524,11 +517,84 @@ async def apply_skill(request: Request, skill_id: int):
|
||||
return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"}
|
||||
|
||||
|
||||
# ── Skill marketplace (v6.6.0, Agent phase 5) ──
|
||||
# Galerie de presets + export/import portable — même implémentation que
|
||||
# l'API publique (/api/v2/skills/*), via app.services.skill_gallery.
|
||||
|
||||
|
||||
@router.get("/skills/gallery")
|
||||
def skills_gallery(request: Request):
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
def install_gallery_skill(request: Request, slug: str, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
def import_skill(request: Request, body: dict = Body(default={})):
|
||||
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
def export_skill(request: Request, skill_id: int):
|
||||
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
def delete_skill(request: Request, skill_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||
|
||||
|
||||
@router.get("/mentions")
|
||||
async def list_mentions(request: Request, q: str = ""):
|
||||
def list_mentions(request: Request, q: str = ""):
|
||||
"""Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »).
|
||||
|
||||
Retourne des sections d'objets FlowDeck que l'utilisateur peut épingler au
|
||||
@@ -551,7 +617,7 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
except (TypeError, ValueError):
|
||||
ws = None
|
||||
if not ws:
|
||||
ws = await _workspace_id(request)
|
||||
ws = _workspace_id(request)
|
||||
|
||||
def dedupe(items: list[dict]) -> list[dict]:
|
||||
seen: set = set()
|
||||
@@ -656,10 +722,9 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
async def add_feedback(request: Request):
|
||||
def add_feedback(request: Request, body: dict = Body(default={})):
|
||||
"""Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
rating = (body.get("rating") or "").strip().lower()
|
||||
if rating not in ("up", "down"):
|
||||
raise HTTPException(status_code=400, detail="rating doit être 'up' ou 'down'")
|
||||
@@ -693,8 +758,8 @@ async def add_feedback(request: Request):
|
||||
async def trigger_agent(request: Request, agent_id: int):
|
||||
"""Manually fire a custom agent: create a conversation and run it with the
|
||||
agent's instructions as the objective (falls back to a generic prompt)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
@@ -721,7 +786,7 @@ async def trigger_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def list_tools(request: Request):
|
||||
def list_tools(request: Request):
|
||||
registry = ToolRegistry()
|
||||
tools = registry.schema()
|
||||
return {"tools": tools}
|
||||
@@ -741,7 +806,7 @@ async def list_providers(request: Request):
|
||||
- ``verified`` : the last connection test / model fetch succeeded.
|
||||
- ``functional`` : the provider is ready to chat (verified, or `offline`).
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
llm = LLMClient()
|
||||
cfg = get_llm_config()
|
||||
keys = list_user_llm_keys(user_id)
|
||||
@@ -796,20 +861,19 @@ async def list_providers(request: Request):
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_llm_keys(request: Request):
|
||||
def list_llm_keys(request: Request):
|
||||
"""The user's saved provider keys + API keys (masked)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
return {"keys": list_user_llm_keys(user_id)}
|
||||
|
||||
|
||||
@router.put("/keys/{llm_provider}")
|
||||
async def save_llm_key(request: Request, llm_provider: str):
|
||||
def save_llm_key(request: Request, llm_provider: str, body: dict = Body(default={})):
|
||||
"""Upsert a provider key for the current user (masked in responses)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
api_base_raw = body.get("api_base")
|
||||
raw = upsert_user_llm_key(
|
||||
user_id,
|
||||
@@ -825,9 +889,9 @@ async def save_llm_key(request: Request, llm_provider: str):
|
||||
|
||||
|
||||
@router.delete("/keys/{llm_provider}")
|
||||
async def delete_llm_key(request: Request, llm_provider: str):
|
||||
def delete_llm_key(request: Request, llm_provider: str):
|
||||
"""Remove a saved provider key for the current user."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -842,7 +906,7 @@ async def test_user_llm_key(request: Request, llm_provider: str):
|
||||
On success the provider is flagged ``verified`` so it can be offered in the
|
||||
Agent panel; on failure the stored error is kept for display in Settings.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -897,7 +961,7 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
A successful fetch proves connectivity, so when it used the *stored* key the
|
||||
provider is flagged ``verified`` (functional) for the Agent panel.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -921,9 +985,32 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
return {"ok": False, "provider": provider, "error": str(exc)}
|
||||
|
||||
|
||||
def _check_api_base(value: str) -> str:
|
||||
"""A14 : `api_base` doit être une URL http(s) sans identifiants.
|
||||
|
||||
ponytail: les hôtes PRIVÉS restent acceptés — le provider par défaut du
|
||||
produit est `http://localhost:11434/v1` (Ollama, `llm_client.PROVIDERS`) et
|
||||
le verrou nommé par l'audit (un anonymous qui oriente le `ping()` du
|
||||
serveur) est neutralisé par `_current_admin` (401 sans session / 403 non
|
||||
admin). Pour verrouiller plus tard : allowlist des providers locaux ou un
|
||||
settings `llm_allow_private=false`.
|
||||
"""
|
||||
url = (value or "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail=f"api_base invalide: {url!r}")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="api_base ne doit pas contenir d'identifiants")
|
||||
return url
|
||||
|
||||
|
||||
@router.patch("/providers")
|
||||
async def update_provider_config(request: Request):
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower()
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -932,7 +1019,7 @@ async def update_provider_config(request: Request):
|
||||
provider=provider or None,
|
||||
model=(body.get("model") or "").strip() or None,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
clear_keys=(provider == "offline"),
|
||||
)
|
||||
llm = LLMClient()
|
||||
@@ -953,7 +1040,7 @@ async def test_provider_config(request: Request):
|
||||
A successful test flags the workspace default provider as ``verified`` so it
|
||||
becomes available (functional) for every user in the Agent panel.
|
||||
"""
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower() or None
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -961,7 +1048,7 @@ async def test_provider_config(request: Request):
|
||||
llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=body.get("api_key"),
|
||||
api_base=(body.get("api_base") or "").strip() or None,
|
||||
api_base=_check_api_base(body.get("api_base") or "") or None,
|
||||
)
|
||||
try:
|
||||
resp = await llm.ping(model=(body.get("model") or "").strip() or None)
|
||||
@@ -985,7 +1072,7 @@ async def test_provider_config(request: Request):
|
||||
|
||||
|
||||
@router.get("/{agent_id}")
|
||||
async def get_agent(request: Request, agent_id: int):
|
||||
def get_agent(request: Request, agent_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -994,8 +1081,7 @@ async def get_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.put("/{agent_id}")
|
||||
async def update_agent(request: Request, agent_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_agent(request: Request, agent_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
@@ -1020,7 +1106,7 @@ async def update_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.delete("/{agent_id}")
|
||||
async def delete_agent(request: Request, agent_id: int):
|
||||
def delete_agent(request: Request, agent_id: int):
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
|
||||
+48
-39
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -15,7 +15,27 @@ from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api"], prefix="/api")
|
||||
|
||||
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
|
||||
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
|
||||
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
|
||||
|
||||
|
||||
async def _require_session_or_bearer(request: Request) -> None:
|
||||
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
|
||||
if request.url.path in _API_PUBLIC_PATHS:
|
||||
return
|
||||
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
return
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
from app.routers.public_api import verify_token
|
||||
verify_token(auth)
|
||||
return
|
||||
raise HTTPException(401, "Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
|
||||
|
||||
# ── Simple rate limiter (in-memory, per-IP) ──
|
||||
_rate_limit_store: dict[str, tuple[float, int]] = {}
|
||||
@@ -26,7 +46,7 @@ def _check_rate_limit(request: Request) -> bool:
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
now = datetime.utcnow().timestamp()
|
||||
now = datetime.now(UTC).replace(tzinfo=None).timestamp()
|
||||
window_start, count = _rate_limit_store.get(ip, (0, 0))
|
||||
if now - window_start > 60:
|
||||
_rate_limit_store[ip] = (now, 1)
|
||||
@@ -47,12 +67,12 @@ async def health(request: Request):
|
||||
conn.execute("SELECT 1")
|
||||
db_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("health")
|
||||
try:
|
||||
await gitea.get_user_repos(page=1, limit=1)
|
||||
gitea_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("health")
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
@@ -63,7 +83,7 @@ async def health(request: Request):
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats():
|
||||
def stats():
|
||||
"""Global stats for dashboard."""
|
||||
with get_conn() as conn:
|
||||
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
|
||||
@@ -79,22 +99,6 @@ async def stats():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
|
||||
"""List Gitea projects (JSON)."""
|
||||
try:
|
||||
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||
if search:
|
||||
q = search.lower()
|
||||
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
|
||||
if not show_archived:
|
||||
repos = [r for r in repos if not r.get("archived", False)]
|
||||
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||
except Exception:
|
||||
repos = []
|
||||
return {"projects": repos}
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_card(
|
||||
request: Request,
|
||||
@@ -175,7 +179,7 @@ async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
|
||||
|
||||
|
||||
@router.post("/col-mapping")
|
||||
async def set_col_mapping(
|
||||
def set_col_mapping(
|
||||
owner: str = Query(...),
|
||||
repo: str = Query(...),
|
||||
column: str = Query(...),
|
||||
@@ -205,7 +209,7 @@ async def set_col_mapping(
|
||||
|
||||
|
||||
@router.delete("/col-mapping")
|
||||
async def delete_col_mapping(
|
||||
def delete_col_mapping(
|
||||
owner: str = Query(...),
|
||||
repo: str = Query(...),
|
||||
column: str = Query(...),
|
||||
@@ -230,7 +234,7 @@ async def delete_col_mapping(
|
||||
|
||||
|
||||
@router.get("/board-config/{owner}/{repo}")
|
||||
async def get_board_config(owner: str, repo: str):
|
||||
def get_board_config(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||
@@ -251,7 +255,7 @@ async def get_board_config(owner: str, repo: str):
|
||||
|
||||
|
||||
@router.post("/board-config/{owner}/{repo}")
|
||||
async def update_board_config(
|
||||
def update_board_config(
|
||||
owner: str,
|
||||
repo: str,
|
||||
columns: str = Query(default=""),
|
||||
@@ -440,8 +444,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
"comments": comments,
|
||||
"checklists": checklists,
|
||||
}
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("card_detail.html")
|
||||
return HTMLResponse(template.render(**ctx))
|
||||
|
||||
@@ -456,7 +460,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
# ── v0.5.0: Checklists ──
|
||||
|
||||
@router.post("/checklists/{owner}/{repo}/{issue_id}")
|
||||
async def create_checklist(
|
||||
def create_checklist(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -480,7 +484,7 @@ async def create_checklist(
|
||||
|
||||
|
||||
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
|
||||
async def add_checklist_item(
|
||||
def add_checklist_item(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -498,7 +502,7 @@ async def add_checklist_item(
|
||||
|
||||
|
||||
@router.patch("/checklist-items/{item_id}")
|
||||
async def toggle_checklist_item(
|
||||
def toggle_checklist_item(
|
||||
item_id: int,
|
||||
checked: bool = Query(default=False),
|
||||
content: str = Query(default=""),
|
||||
@@ -520,7 +524,7 @@ async def toggle_checklist_item(
|
||||
|
||||
|
||||
@router.delete("/checklist-items/{item_id}")
|
||||
async def delete_checklist_item(item_id: int):
|
||||
def delete_checklist_item(item_id: int):
|
||||
"""Delete a checklist item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
|
||||
@@ -529,7 +533,7 @@ async def delete_checklist_item(item_id: int):
|
||||
|
||||
|
||||
@router.delete("/checklists/{checklist_id}")
|
||||
async def delete_checklist(checklist_id: int):
|
||||
def delete_checklist(checklist_id: int):
|
||||
"""Delete a checklist and all its items."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
|
||||
@@ -541,14 +545,19 @@ async def delete_checklist(checklist_id: int):
|
||||
# ── v1.0.0: User management ──
|
||||
|
||||
@router.get("/users/me")
|
||||
async def get_my_profile(request: Request):
|
||||
def get_my_profile(request: Request):
|
||||
"""Get current user profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return {"login": "guest", "full_name": "Guest", "email": ""}
|
||||
with get_conn() as conn:
|
||||
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
|
||||
# password_hash, login_attempts et locked_until.
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, last_login, created_at "
|
||||
"FROM users WHERE login=?",
|
||||
(user.get("login", ""),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
@@ -556,7 +565,7 @@ async def get_my_profile(request: Request):
|
||||
|
||||
|
||||
@router.put("/users/me")
|
||||
async def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
email: str = Query(default="")):
|
||||
"""Update current user's local profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -575,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
|
||||
# ── v0.5.0: Card priority & due date ──
|
||||
|
||||
@router.post("/card/{owner}/{repo}/{issue_id}")
|
||||
async def update_card(
|
||||
def update_card(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -664,7 +673,7 @@ async def capture_frontend_error(request: Request):
|
||||
|
||||
|
||||
@router.get("/frontend-errors")
|
||||
async def get_frontend_errors(request: Request, clear: bool = True):
|
||||
def get_frontend_errors(request: Request, clear: bool = True):
|
||||
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
|
||||
errors = list(_frontend_errors)
|
||||
if clear:
|
||||
|
||||
+240
-580
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,657 @@
|
||||
"""FlowDeck — Public API v2 : Agent & Skill marketplace (v6.6.0, phase 5).
|
||||
|
||||
Thin Bearer+scopes wrappers over the existing agent logic (AgentEngine,
|
||||
`agent_skills`, the gallery service) so third-party integrations can drive
|
||||
FlowDeck Agent without a browser session:
|
||||
|
||||
* ``/api/v2/agents`` — agents CRUD, conversations, synchronous runs (JSON,
|
||||
the SSE stream stays an internal/UI concern), audit journal & rollback.
|
||||
* ``/api/v2/skills`` — the skill marketplace: CRUD, portable export/import and
|
||||
the built-in gallery of installable presets.
|
||||
|
||||
Rules honoured (see docs/API_GUIDE_V6.md): one code path (the engine and the
|
||||
gallery service are reused, never re-implemented), JSON only, no secrets or
|
||||
internal columns, rate limit + audit + idempotency on every mutation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.agent import _default_agent
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
)
|
||||
from app.services.llm_client import LLMClient
|
||||
from app.services.llm_config import get_user_llm_key
|
||||
|
||||
router = APIRouter(prefix="/api/v2", tags=["api-v2-agent"])
|
||||
|
||||
|
||||
# ── Shared guards ──────────────────────────────────────────────────────────
|
||||
|
||||
def _guard(request: Request, authorization: str | None, *, write: bool = False) -> dict:
|
||||
"""Bearer auth + per-token rate limit (+ write scope when required)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_v2_rate_limit(user.get("_token_hash"), ip):
|
||||
raise HTTPException(429, "Rate limit exceeded: 300 req/min per token")
|
||||
if write and not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
|
||||
|
||||
async def _json_body(request: Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def _workspace_of(request: Request, body: dict | None = None) -> int | None:
|
||||
"""Workspace resolution mirrors the internal agent router: explicit param
|
||||
wins, then the token's own workspace, else NULL (shared/global scope)."""
|
||||
body = body or {}
|
||||
raw = body.get("workspace_id") or request.query_params.get("workspace_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _owned_conversation(conn, conversation_id: int, user_id: int):
|
||||
"""Conversation visible to this token's user (ownership is enforced here,
|
||||
unlike the session router where the browser is already authenticated)."""
|
||||
return conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
(conversation_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) -> AgentEngine:
|
||||
engine = AgentEngine(user_id, workspace_id=workspace_id)
|
||||
if provider:
|
||||
user_key = get_user_llm_key(user_id, provider)
|
||||
if user_key and user_key.get("api_key"):
|
||||
engine.llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=user_key["api_key"],
|
||||
api_base=user_key.get("api_base") or None,
|
||||
)
|
||||
else:
|
||||
engine.llm = LLMClient(provider=provider)
|
||||
return engine
|
||||
|
||||
|
||||
# ── Agents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agents")
|
||||
def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user["id"])
|
||||
clause = "WHERE workspace_id IS ? OR workspace_id=?"
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM agents {clause}", (ws, ws)).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM agents {clause} ORDER BY agent_type, name LIMIT ? OFFSET ?",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"agents": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents")
|
||||
async def create_agent_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agents (workspace_id, name, icon, agent_type, description,
|
||||
system_instructions, model, scope_json, trigger_json, approval_mode, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(ws, name, body.get("icon", "🤖"), body.get("agent_type", "custom"),
|
||||
body.get("description", ""), body.get("system_instructions", ""),
|
||||
body.get("model", "gpt-4o"),
|
||||
json.dumps(body.get("scope", {})), json.dumps(body.get("trigger", {})),
|
||||
body.get("approval_mode", "auto"), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
agent_id = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(409, f"Cannot create agent: {exc}") from exc
|
||||
audit_log(user, "agent.create", "agent", agent_id, name, request)
|
||||
data = {"id": agent_id, "name": name, "status": "created", "agent": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}")
|
||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
sets, params = [], []
|
||||
for col in ("name", "icon", "description", "system_instructions", "model",
|
||||
"approval_mode", "is_active"):
|
||||
if col in body:
|
||||
sets.append(f"{col}=?")
|
||||
params.append(body[col])
|
||||
if "scope" in body:
|
||||
sets.append("scope_json=?")
|
||||
params.append(json.dumps(body["scope"]))
|
||||
if "trigger" in body:
|
||||
sets.append("trigger_json=?")
|
||||
params.append(json.dumps(body["trigger"]))
|
||||
if sets:
|
||||
params.append(agent_id)
|
||||
conn.execute(f"UPDATE agents SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
audit_log(user, "agent.update", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
|
||||
raise HTTPException(404, "Agent not found")
|
||||
conn.execute("DELETE FROM agents WHERE id=?", (agent_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.delete", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
|
||||
|
||||
@router.get("/agents/conversations")
|
||||
def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_conversations WHERE user_id=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_conversations WHERE user_id=?
|
||||
ORDER BY updated_at DESC LIMIT ? OFFSET ?""",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"conversations": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents/conversations")
|
||||
async def create_conversation_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
agent_id = body.get("agent_id")
|
||||
with get_conn() as conn:
|
||||
if agent_id is not None:
|
||||
agent = conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
agent_id = agent["id"]
|
||||
else:
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(agent_id, user["id"], body.get("title") or "New conversation",
|
||||
json.dumps({"workspace_id": ws}),
|
||||
body.get("provider") or "", body.get("model") or ""),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conv_id,)).fetchone()
|
||||
audit_log(user, "agent.conversation.create", "agent_conversation", conv_id, "", request)
|
||||
data = {"id": conv_id, "status": "created", "conversation": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}")
|
||||
def get_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
messages = conn.execute(
|
||||
"SELECT * FROM agent_messages WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"conversation": row_to_dict(conv), "messages": [row_to_dict(m) for m in messages]}
|
||||
|
||||
|
||||
@router.delete("/agents/conversations/{conversation_id}")
|
||||
def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
conn.execute("DELETE FROM agent_conversations WHERE id=?", (conversation_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.conversation.delete", "agent_conversation", conversation_id, "", request)
|
||||
return {"id": conversation_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}/actions")
|
||||
def list_actions_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"actions": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/agents/actions/{action_id}/undo")
|
||||
def undo_action_v2(action_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT a.id FROM agent_actions a
|
||||
JOIN agent_conversations c ON c.id = a.conversation_id
|
||||
WHERE a.id=? AND c.user_id=?""",
|
||||
(action_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Action not found")
|
||||
try:
|
||||
undo_action(action_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(500, f"Rollback failed: {exc}") from exc
|
||||
audit_log(user, "agent.action.undo", "agent_action", action_id, "", request)
|
||||
return {"id": action_id, "status": "reverted"}
|
||||
|
||||
|
||||
# ── Runs (JSON — the SSE stream stays internal) ────────────────────────────
|
||||
|
||||
def _collect_run_events(events: list[dict]) -> dict:
|
||||
"""Aggregate an engine event stream into a JSON run result.
|
||||
|
||||
Engine events are flat (``{"type": "final", "content": ...}``), the same
|
||||
shape the SSE panel consumes.
|
||||
"""
|
||||
final = None
|
||||
reasoning = []
|
||||
actions = []
|
||||
error = None
|
||||
for ev in events:
|
||||
etype = ev.get("type")
|
||||
if etype == "final":
|
||||
final = ev.get("content") or final
|
||||
elif etype == "reasoning":
|
||||
reasoning.append(ev.get("content") or "")
|
||||
elif etype == "action":
|
||||
actions.append({k: v for k, v in ev.items() if k != "type"})
|
||||
elif etype == "error":
|
||||
error = ev.get("message") or "run failed"
|
||||
return {
|
||||
"status": "failed" if error else "completed",
|
||||
"final": final,
|
||||
"error": error,
|
||||
"reasoning": reasoning,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/agents/conversations/{conversation_id}/run")
|
||||
async def run_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronous agent run: buffers the engine stream and returns JSON.
|
||||
|
||||
Third parties get one HTTP round-trip instead of an SSE subscription; the
|
||||
same AgentEngine, permissions, journal and webhooks are used as the UI.
|
||||
"""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
objective = (body.get("message") or body.get("objective") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(400, "message is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
eff_provider = body.get("provider") or conv["provider"] or None
|
||||
eff_model = body.get("model") or conv["model"] or None
|
||||
if body.get("provider") is not None or body.get("model") is not None:
|
||||
conn.execute(
|
||||
"UPDATE agent_conversations SET provider=?, model=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body.get("provider", conv["provider"] or ""),
|
||||
body.get("model", conv["model"] or ""), conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
conv_context = {}
|
||||
try:
|
||||
conv_context = json.loads(conv["context_json"] or "{}") or {}
|
||||
except (TypeError, ValueError):
|
||||
conv_context = {}
|
||||
|
||||
ws = _workspace_of(request, body)
|
||||
if ws is None:
|
||||
ws = conv_context.get("workspace_id")
|
||||
|
||||
engine = _engine_for(user["id"], ws, eff_provider)
|
||||
started = time.time()
|
||||
events = [
|
||||
ev async for ev in engine.run(
|
||||
conversation_id, objective,
|
||||
model=eff_model,
|
||||
mentions=body.get("mentions"),
|
||||
files=body.get("files"),
|
||||
skill_id=body.get("skill_id"),
|
||||
skill_ids=body.get("skill_ids"),
|
||||
extra_context=body.get("context"),
|
||||
)
|
||||
]
|
||||
result = _collect_run_events(events)
|
||||
with get_conn() as conn:
|
||||
actions = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
payload = {
|
||||
"conversation_id": conversation_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": [row_to_dict(a) for a in actions],
|
||||
"events": events,
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.run", "agent_conversation", conversation_id, objective[:200], request)
|
||||
status_code = 200 if result["status"] == "completed" else 500
|
||||
data = payload
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, status_code)
|
||||
return JSONResponse(content=data, status_code=status_code)
|
||||
|
||||
|
||||
@router.post("/agents/{agent_id}/trigger")
|
||||
async def trigger_agent_v2(agent_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Fire a custom agent from an external integration (JSON, synchronous)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], f"Run: {agent['name']}", json.dumps({"workspace_id": ws})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
objective = (agent["system_instructions"] or "").strip() or f"Exécute l'agent « {agent['name']} »."
|
||||
if body.get("message"):
|
||||
objective = f"{objective}\n\n{body['message']}"
|
||||
engine = _engine_for(user["id"], ws, agent["model"] or None)
|
||||
started = time.time()
|
||||
events = [ev async for ev in engine.run(conv_id, objective, model=agent["model"])]
|
||||
result = _collect_run_events(events)
|
||||
payload = {
|
||||
"conversation_id": conv_id,
|
||||
"agent_id": agent_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": result["actions"],
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.trigger", "agent", agent_id, objective[:200], request)
|
||||
return JSONResponse(content=payload, status_code=200 if result["status"] == "completed" else 500)
|
||||
|
||||
|
||||
# ── Skill marketplace ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/skills")
|
||||
def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?",
|
||||
(ws, ws),
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?
|
||||
ORDER BY name LIMIT ? OFFSET ?""",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"skills": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(
|
||||
{k: body[k] for k in ("name", "description", "prompt_template", "allowed_tools")
|
||||
if k in body} | {"format": skill_gallery.EXPORT_FORMAT}
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.create", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "created" if created else "updated", "skill": row_to_dict(row) if row else {}}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
# Gallery & import are static segments: declared before /skills/{skill_id} so
|
||||
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
||||
@router.get("/skills/gallery")
|
||||
def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/v2/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill_v2(slug: str, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(404, f"Unknown gallery skill: {slug}")
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.gallery.install", "skill", row.get("id"), slug, request)
|
||||
data = {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row_to_dict(row)}
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Import a portable skill document (from another FlowDeck instance)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.import", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}")
|
||||
def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
def export_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
def delete_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "skill.delete", "skill", skill_id, row["name"] or "", request)
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Open a conversation pre-loaded with the skill (ready to run)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], skill["name"],
|
||||
json.dumps({"workspace_id": ws if ws is not None else skill["workspace_id"],
|
||||
"skill_id": skill_id})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "skill.apply", "skill", skill_id, skill["name"], request)
|
||||
return JSONResponse(content={"conversation_id": conv_id, "skill": skill["name"],
|
||||
"status": "ready"}, status_code=201)
|
||||
@@ -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")
|
||||
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})
|
||||
+189
-25
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}/auth/callback"
|
||||
|
||||
def _with_nonce(html: str) -> str:
|
||||
"""A20 : injecte le nonce CSP au moment du rendu.
|
||||
|
||||
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
|
||||
requête, donc il ne peut être figé qu'ici.
|
||||
"""
|
||||
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
|
||||
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -63,6 +73,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
|
||||
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
||||
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
||||
.oauth-btn:hover{background:#333;}
|
||||
.sso-btn{border-color:rgba(35,131,226,.5);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -88,11 +99,17 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
|
||||
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
||||
</form>
|
||||
<div class="oauth-section">
|
||||
<div class="oauth-section" id="oauth-section">
|
||||
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
||||
</div>
|
||||
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
|
||||
<div class="oauth-section" id="sso-section" style="display:none">
|
||||
<p id="sso-divider" style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||
<div id="sso-buttons"></div>
|
||||
<p id="sso-only-note" style="display:none;font-size:12px;color:rgba(255,255,255,.45);margin-top:12px;line-height:1.5;">This instance only accepts your organization account — local login is disabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Show session expired banner if ?expired=1 in URL
|
||||
@@ -101,15 +118,47 @@ let mode='login';
|
||||
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
|
||||
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
|
||||
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
||||
// SSO buttons (v6.7.0) — rendered from /api/v2/sso/providers
|
||||
(async function loadSsoProviders(){
|
||||
try{
|
||||
const r = await fetch('/api/v2/sso/providers');
|
||||
if(!r.ok) return;
|
||||
const d = await r.json();
|
||||
const providers = d.providers || [];
|
||||
if(!providers.length) return;
|
||||
const wrap = document.getElementById('sso-buttons');
|
||||
providers.forEach(function(p){
|
||||
const b = document.createElement('button');
|
||||
b.className = 'oauth-btn sso-btn';
|
||||
b.style.marginBottom = '8px';
|
||||
b.title = 'Sign in with ' + (p.name || 'SSO');
|
||||
b.onclick = function(){ window.location = p.login_url; };
|
||||
const icon = document.createElement('span'); icon.textContent = p.icon || '🏢';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (mode === 'register' ? 'Sign up' : 'Login') + ' with ' + (p.name || 'SSO');
|
||||
b.appendChild(icon); b.appendChild(label);
|
||||
wrap.appendChild(b);
|
||||
});
|
||||
document.getElementById('sso-section').style.display = 'block';
|
||||
if(d.sso_only){
|
||||
// Local auth is refused server-side too — don't show a dead form.
|
||||
const form = document.getElementById('login-form'); if(form) form.style.display = 'none';
|
||||
const tabs = document.querySelector('.tabs'); if(tabs) tabs.style.display = 'none';
|
||||
const oauth = document.getElementById('oauth-section'); if(oauth) oauth.style.display = 'none';
|
||||
const note = document.getElementById('sso-only-note'); if(note) note.style.display = 'block';
|
||||
const intro = document.querySelector('.login-box p'); if(intro) intro.textContent = 'Sign in with your organization account to continue';
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_page(request: Request):
|
||||
def register_page(request: Request):
|
||||
"""Show the registration page (local login page with register tab active)."""
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
|
||||
'class="tab active" onclick="switchTab(\'login\')"',
|
||||
'class="tab" onclick="switchTab(\'login\')"'
|
||||
).replace(
|
||||
@@ -124,16 +173,16 @@ async def register_page(request: Request):
|
||||
).replace(
|
||||
'id="submit-btn">Login<',
|
||||
'id="submit-btn">Register<'
|
||||
), status_code=200)
|
||||
)), status_code=200)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, provider: str = Query("gitea")):
|
||||
def login(request: Request, provider: str = Query("gitea")):
|
||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
from fastapi.responses import HTMLResponse
|
||||
if provider == "local":
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
@@ -171,15 +220,11 @@ async def login(request: Request, provider: str = Query("gitea")):
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
def register(request: Request, body: dict = Body(default={})):
|
||||
"""Register a new local account."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||
@@ -191,6 +236,16 @@ async def register(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
# SSO-only instance (v6.7.0): local registration is refused — accounts are
|
||||
# auto-provisioned by the IdP instead (admins still come from Settings).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
if is_sso_only():
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"error": "Registration is disabled — sign in with your organization SSO"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
||||
if existing:
|
||||
@@ -218,7 +273,7 @@ async def register(request: Request):
|
||||
|
||||
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
def local_login(request: Request, body: dict = Body(default={})):
|
||||
"""Login with email + password."""
|
||||
import time
|
||||
|
||||
@@ -226,10 +281,6 @@ async def local_login(request: Request):
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import is_locked, verify_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
|
||||
@@ -260,13 +311,41 @@ async def local_login(request: Request):
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
# Successful login
|
||||
# Successful local login — SSO-only instances keep a way in for admins
|
||||
# only (every other account must use the IdP, design §7.1).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
if is_sso_only() and not ud.get("is_admin"):
|
||||
return JSONResponse(
|
||||
{"error": "Local login is disabled on this instance — sign in with SSO"},
|
||||
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"]}})
|
||||
@@ -323,7 +402,7 @@ async def callback(
|
||||
oauth_mode = request.session.pop("oauth_mode", "")
|
||||
if oauth_mode == "link":
|
||||
from app.auth.session import get_current_user as gcu
|
||||
current = await gcu(request)
|
||||
current = gcu(request)
|
||||
if not current:
|
||||
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
|
||||
from app.db import get_conn as _gc
|
||||
@@ -373,22 +452,107 @@ async def callback(
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session and redirect to login page."""
|
||||
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
def logout(request: Request):
|
||||
"""Clear session and redirect to login page.
|
||||
|
||||
SAML sessions additionally hand over to the IdP's Single Logout when one
|
||||
is configured (the actual cookie clearing happens on the SLO route).
|
||||
"""
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(cookie) if cookie else None
|
||||
local_target = "/auth/login?provider=local"
|
||||
|
||||
if user and user.get("_sso_name_id"):
|
||||
# SSO session → let /auth/saml/logout revoke locally + notify the IdP.
|
||||
return RedirectResponse(url=f"/auth/saml/logout?next={local_target}", status_code=302)
|
||||
|
||||
response = RedirectResponse(url=local_target, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/user")
|
||||
async def current_user(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
def local_verify(request: Request, body: dict = Body(default={})):
|
||||
"""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
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
def twofa_activate(request: Request, body: dict = Body(default={})):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
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")
|
||||
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."""
|
||||
@@ -403,4 +567,4 @@ def _log_login(user_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_log_login")
|
||||
|
||||
+156
-12
@@ -4,14 +4,28 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, 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"])
|
||||
|
||||
|
||||
def _require_session(request: Request) -> None:
|
||||
"""A13 : toute la route (CRUD, run, press-button) exige une session."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
|
||||
router = APIRouter(tags=["automations"], dependencies=[Depends(_require_session)])
|
||||
|
||||
TRIGGER_TYPES = ("event", "cron", "button")
|
||||
|
||||
@@ -57,7 +71,7 @@ def _validate_payload(body: dict) -> None:
|
||||
|
||||
|
||||
@router.get("/workspace/automations")
|
||||
async def list_automations(request: Request):
|
||||
def list_automations(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
@@ -65,11 +79,10 @@ async def list_automations(request: Request):
|
||||
|
||||
|
||||
@router.post("/workspace/automations")
|
||||
async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_automation(request: Request, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user.get("id") or 1
|
||||
by = user["id"]
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO automations
|
||||
@@ -95,7 +108,7 @@ async def create_automation(request: Request):
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}")
|
||||
async def get_automation(request: Request, auto_id: int):
|
||||
def get_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -104,8 +117,7 @@ async def get_automation(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}")
|
||||
async def update_automation(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_automation(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
@@ -133,7 +145,7 @@ async def update_automation(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/{auto_id}")
|
||||
async def delete_automation(request: Request, auto_id: int):
|
||||
def delete_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
||||
conn.commit()
|
||||
@@ -164,7 +176,7 @@ async def run_automation_button(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/runs")
|
||||
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automation_runs WHERE automation_id=?
|
||||
@@ -172,3 +184,135 @@ 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")
|
||||
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")
|
||||
def create_step(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
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}")
|
||||
def update_step(request: Request, step_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
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}")
|
||||
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")
|
||||
def set_trigger_mode(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
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
|
||||
|
||||
+172
-169
@@ -3,18 +3,21 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -63,7 +66,7 @@ def _ensure_block_ids(blocks) -> None:
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
@@ -95,7 +98,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
async def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
@@ -121,16 +124,12 @@ async def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
async def set_page_lock(request: Request, page_id: int):
|
||||
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
||||
admins and the page creator)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
@@ -143,21 +142,17 @@ async def set_page_lock(request: Request, page_id: int):
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]})
|
||||
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]}))
|
||||
return {"status": "ok", "is_locked": int(locked)}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/options")
|
||||
async def set_page_options(request: Request, page_id: int):
|
||||
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: page layout options — full-width and compact typography."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
@@ -176,7 +171,7 @@ async def set_page_options(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
async def list_page_templates_api(request: Request):
|
||||
def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
@@ -196,16 +191,12 @@ async def list_page_templates_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
async def create_page_template(request: Request):
|
||||
def create_page_template(request: Request, body: dict = Body(default={})):
|
||||
"""v5.12.0: save the current page (or a raw block list) as a personal
|
||||
global template: {name, icon?, description?, page_id? | blocks?}."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -236,17 +227,13 @@ async def create_page_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
async def use_page_template(request: Request, template_id: int):
|
||||
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: instantiate a page from a template (built-in or user).
|
||||
|
||||
Body: {key?} for built-ins OR uses the row id for user templates.
|
||||
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
@@ -328,8 +315,8 @@ async def use_page_template(request: Request, template_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name}))
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
@@ -694,7 +681,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
elif ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
@@ -780,7 +767,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
|
||||
"active_ws_name": active_ws_name,
|
||||
@@ -803,7 +790,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -849,15 +836,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
|
||||
"""Return groups from Gitea labels/milestones or fallback to defaults."""
|
||||
try:
|
||||
labels = json.loads(
|
||||
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
|
||||
) if False else []
|
||||
except Exception:
|
||||
labels = []
|
||||
return labels if labels else ["Design", "Engineering", "No Team"]
|
||||
|
||||
|
||||
async def asyncio_get_labels(owner: str, repo: str):
|
||||
@@ -905,10 +883,10 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -949,7 +927,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -961,7 +939,7 @@ async def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
async def add_favorite(request: Request, page_id: int):
|
||||
def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -979,14 +957,14 @@ async def add_favorite(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
async def remove_favorite(request: Request, page_id: int):
|
||||
def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -994,17 +972,16 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("remove_favorite")
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
async def update_share(request: Request, page_id: int):
|
||||
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Save share settings for a page."""
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
@@ -1017,63 +994,48 @@ async def update_share(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
async def publish_page(request: Request, page_id: int):
|
||||
def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
import secrets
|
||||
slug = "p-" + secrets.token_urlsafe(8)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
slug, title = publish(page_id)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
async def unpublish_page(request: Request, page_id: int):
|
||||
def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
async def list_trash(request: Request):
|
||||
def list_trash(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
async def restore_page(request: Request, page_id: int):
|
||||
def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.restored", {"page_id": page_id})
|
||||
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("restore_page")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
async def permanent_delete(request: Request, page_id: int):
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
@@ -1082,9 +1044,9 @@ async def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
@@ -1093,7 +1055,7 @@ async def trash_page(request: Request):
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
@@ -1101,12 +1063,8 @@ async def list_synced_blocks_api(request: Request, workspace: str = Query(defaul
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
async def create_synced_block_api(request: Request):
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
@@ -1168,7 +1126,7 @@ async def delete_synced_block_api(request: Request, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
async def get_synced_block_api(sid: int):
|
||||
def get_synced_block_api(sid: int):
|
||||
"""Get a synced block by id."""
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
@@ -1178,12 +1136,8 @@ async def get_synced_block_api(sid: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
async def add_synced_to_page(request: Request, page_id: int):
|
||||
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Add a synced block reference to a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import add_page_synced, get_synced_block
|
||||
sid = body.get("synced_block_id")
|
||||
sb = get_synced_block(sid)
|
||||
@@ -1194,7 +1148,7 @@ async def add_synced_to_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
||||
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
"""Remove a synced block reference from a page (unsync)."""
|
||||
from app.services.synced_blocks import remove_page_synced
|
||||
remove_page_synced(page_id, sid)
|
||||
@@ -1202,7 +1156,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/synced")
|
||||
async def get_page_synced_refs(request: Request, page_id: int):
|
||||
def get_page_synced_refs(request: Request, page_id: int):
|
||||
"""Get all synced block references for a page."""
|
||||
from app.services.synced_blocks import get_page_synced
|
||||
return {"synced_blocks": get_page_synced(page_id)}
|
||||
@@ -1211,9 +1165,9 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
@@ -1239,8 +1193,8 @@ async def board_view(
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
@@ -1300,12 +1254,12 @@ async def board_view(
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
@router.get("/api/properties/{owner}/{repo}")
|
||||
async def get_properties(owner: str, repo: str):
|
||||
def get_properties(owner: str, repo: str):
|
||||
return {"properties": _get_project_properties(owner, repo)}
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}")
|
||||
async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
prop_type: str = Query(default="select"),
|
||||
options: str = Query(default="")):
|
||||
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
||||
@@ -1322,7 +1276,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
|
||||
|
||||
@router.delete("/api/properties/{owner}/{repo}")
|
||||
async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
@@ -1333,7 +1287,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}/values")
|
||||
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
name: str = Query(...), value: str = Query(default="")):
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
@@ -1353,7 +1307,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
@router.get("/api/ai-keywords/{owner}/{repo}")
|
||||
async def get_ai_keywords(owner: str, repo: str):
|
||||
def get_ai_keywords(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
||||
@@ -1378,12 +1332,15 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
async def create_page(request: Request, title: str = Query(default=""),
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
"""Create a new Markdown page, optionally as a sub-page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
# A7 : la création de page exige une session (route sortue de la liste CSRF).
|
||||
raise HTTPException(401, "Authentication required")
|
||||
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
|
||||
page_title = title.strip() if title else ""
|
||||
try:
|
||||
@@ -1403,8 +1360,8 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
@@ -1413,14 +1370,14 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
async def get_page(request: Request, page_id: int):
|
||||
def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
# No session → legacy single-user behaviour (matches collections `_require_view`).
|
||||
if user and user.get("id"):
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1429,7 +1386,7 @@ async def get_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
@@ -1455,23 +1412,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
if content_format:
|
||||
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"})
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
v5.14.0: synced block references are tracked in page_synced_blocks.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
@@ -1521,8 +1475,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -1559,7 +1514,7 @@ def _block_texts(b: dict) -> list[str]:
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
async def page_backlinks(request: Request, page_id: int):
|
||||
def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
@@ -1607,7 +1562,7 @@ async def page_backlinks(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
async def page_versions(request: Request, page_id: int):
|
||||
def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1621,7 +1576,7 @@ async def page_versions(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
@@ -1635,8 +1590,8 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"})
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
@@ -1644,7 +1599,7 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
async def duplicate_page(request: Request, page_id: int):
|
||||
def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -1680,8 +1635,8 @@ async def duplicate_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
await fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")})
|
||||
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")}))
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
@@ -1689,8 +1644,7 @@ async def duplicate_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
@@ -1726,7 +1680,7 @@ async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
|
||||
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"{stamp}_{name}"
|
||||
@@ -1767,7 +1721,7 @@ async def set_page_cover(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
async def remove_page_cover(request: Request, page_id: int):
|
||||
def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -1775,9 +1729,8 @@ async def remove_page_cover(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
async def set_page_icon(request: Request, page_id: int):
|
||||
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
||||
body = await request.json()
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
@@ -1883,7 +1836,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,22 +1848,69 @@ 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)
|
||||
try:
|
||||
data = await fetch_og_metadata(url)
|
||||
except ValueError as exc:
|
||||
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
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):
|
||||
def resolve_embed(request: Request, body: dict = Body(...)):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
@@ -1917,7 +1921,7 @@ async def resolve_embed(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
@@ -1925,10 +1929,6 @@ async def move_page(request: Request, page_id: int):
|
||||
- parent_id: change parent (0 = root level)
|
||||
- new_order: position among siblings (0 = append)
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
@@ -1959,13 +1959,13 @@ async def move_page(request: Request, page_id: int):
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id})
|
||||
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
@@ -1979,19 +1979,19 @@ async def delete_page(request: Request, page_id: int):
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
@@ -2084,12 +2084,15 @@ async def sync_project(owner: str, repo: str):
|
||||
if board:
|
||||
board_id = board["id"]
|
||||
columns = json.loads(board["columns_json"])
|
||||
# A23 : un seul executemany pour toutes les cards.
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
[
|
||||
(board_id, issue["number"], _issue_column(issue, columns, board_id))
|
||||
for issue in issues_only
|
||||
],
|
||||
)
|
||||
for issue in issues_only:
|
||||
col = _issue_column(issue, columns, board_id)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
|
||||
(board_id, issue["number"], col),
|
||||
)
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
|
||||
@@ -8,12 +8,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import notifications as notif
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
@@ -49,7 +50,7 @@ def _serialize(rows):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
def list_comments(request: Request, page_id: int):
|
||||
"""List page-level and inline comments for a FlowDeck page."""
|
||||
_current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -70,10 +71,9 @@ async def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Create a page or inline comment. Mentions (@login) notify users."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = (body.get("body") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -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']}”"
|
||||
@@ -118,25 +124,24 @@ async def add_comment(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(_fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid}))
|
||||
mentioned_ids = notif.extract_mentions(text)
|
||||
if mentioned_ids:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_comment")
|
||||
|
||||
return {"id": comment_id, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
async def notify_page_mentions(request: Request, page_id: int):
|
||||
def notify_page_mentions(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Notify users @-mentioned in a page's content (called on save).
|
||||
|
||||
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
|
||||
against a per-page cache so repeated auto-saves don't spam notifications.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = body.get("text") or ""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
@@ -151,17 +156,16 @@ async def notify_page_mentions(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("notify_page_mentions")
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
"""Update a comment body or resolve/unresolve it."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM comments WHERE id=?", (comment_id,)
|
||||
@@ -182,14 +186,14 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if body.get("resolved") and not was_resolved:
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_comment")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment(request: Request, comment_id: int):
|
||||
def delete_comment(request: Request, comment_id: int):
|
||||
"""Delete a comment and its replies."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
+268
-196
@@ -1,16 +1,17 @@
|
||||
"""FlowDeck — Collections router: Notion-style databases (v1.3.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
@@ -26,6 +27,7 @@ from app.services.recurrence import (
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
@@ -42,23 +44,22 @@ def _session_user(request: Request) -> dict | None:
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Return None when a user may view the collection, else raise 404.
|
||||
"""Raise 404 when the user may not view the collection (404 hides it).
|
||||
|
||||
A missing/userless session keeps the legacy single-user behaviour (owner on
|
||||
un-workspaced collections); explicit ``restricted`` / ``private`` collections
|
||||
are hidden for non-owners unless granted.
|
||||
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
||||
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
||||
"""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 403 when the user may not edit pages in the collection."""
|
||||
"""Raise 401/403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
@@ -195,7 +196,7 @@ def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_collections(request: Request):
|
||||
def list_collections(request: Request):
|
||||
"""Page listing all collections in the workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def list_collections_api(request: Request):
|
||||
def list_collections_api(request: Request):
|
||||
"""API: list all collections."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -221,12 +222,8 @@ async def list_collections_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api")
|
||||
async def create_collection_api(request: Request):
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -276,7 +273,7 @@ async def create_collection_api(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -284,12 +281,8 @@ async def create_collection_api(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
async def update_collection_api(request: Request, collection_id: int):
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -315,12 +308,12 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
async def delete_collection_api(request: Request, collection_id: int):
|
||||
def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
@@ -339,13 +332,13 @@ async def delete_collection_api(request: Request, collection_id: int):
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""})
|
||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""}))
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/duplicate")
|
||||
async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
|
||||
sources) into a new collection named '<original> (copy)'."""
|
||||
with get_conn() as conn:
|
||||
@@ -373,20 +366,35 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for p in rows:
|
||||
ncur = conn.execute(
|
||||
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
|
||||
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
|
||||
tuples = [
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"])
|
||||
for p in rows
|
||||
]
|
||||
if tuples:
|
||||
ncur = conn.executemany(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
related_collection_id, reverse_name, relation_property_id,
|
||||
target_property_id, rollup_function, formula_expression,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"]),
|
||||
tuples,
|
||||
)
|
||||
prop_map[p["id"]] = ncur.lastrowid
|
||||
new_ids = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
|
||||
(new_id,),
|
||||
).fetchall()
|
||||
]
|
||||
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
|
||||
for p, new_pid in zip(rows, new_ids, strict=True):
|
||||
prop_map[p["id"]] = new_pid
|
||||
|
||||
# Fix cross-property references after all rows exist (creates may target
|
||||
# columns not inserted yet). Related collection remapped to the copy.
|
||||
@@ -478,7 +486,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": new_id, "name": new_name})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
|
||||
return {"id": new_id, "name": new_name, "status": "duplicated"}
|
||||
|
||||
|
||||
@@ -486,7 +494,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/api")
|
||||
async def get_page_api(request: Request, page_id: int):
|
||||
def get_page_api(request: Request, page_id: int):
|
||||
"""API: get a single page."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
@@ -500,7 +508,7 @@ async def get_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
async def open_row_page_api(request: Request, page_id: int):
|
||||
def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
@@ -526,12 +534,8 @@ async def open_row_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
async def update_page_api(request: Request, page_id: int):
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -583,18 +587,18 @@ async def update_page_api(request: Request, page_id: int):
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.updated", {
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
})
|
||||
await fire_event("collection.page.updated", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
@@ -604,7 +608,7 @@ async def update_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/api")
|
||||
async def delete_page_api(request: Request, page_id: int):
|
||||
def delete_page_api(request: Request, page_id: int):
|
||||
"""API: delete a page from its collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -618,15 +622,15 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.deleted", {
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
})
|
||||
await fire_event("collection.page.deleted", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -634,7 +638,7 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/boards/api")
|
||||
async def list_boards_as_collections(request: Request):
|
||||
def list_boards_as_collections(request: Request):
|
||||
"""API: list all Gitea boards as pseudo-collections."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
boards = GiteaBoardCompat.list_boards_as_collections()
|
||||
@@ -674,14 +678,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
async def list_property_types_api(request: Request):
|
||||
def list_property_types_api(request: Request):
|
||||
"""API: list all available property types."""
|
||||
from app.services.property_types import PROPERTY_TYPES
|
||||
return {"types": PROPERTY_TYPES}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
async def list_properties_api(request: Request, collection_id: int):
|
||||
def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
@@ -704,7 +708,7 @@ async def list_properties_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
async def list_collection_members_api(request: Request, collection_id: int):
|
||||
def list_collection_members_api(request: Request, collection_id: int):
|
||||
"""API: list workspace members available for a ``person`` property.
|
||||
|
||||
Resolves the collection's workspace and returns its members (falling back to
|
||||
@@ -737,7 +741,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/calendar/api")
|
||||
async def collection_calendar_api(request: Request, collection_id: int,
|
||||
def collection_calendar_api(request: Request, collection_id: int,
|
||||
start: str = "", end: str = "",
|
||||
date_property: str = ""):
|
||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||
@@ -822,7 +826,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
|
||||
|
||||
|
||||
@router.get("/timezones/api")
|
||||
async def timezones_api(request: Request):
|
||||
def timezones_api(request: Request):
|
||||
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -835,16 +839,12 @@ async def timezones_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
async def set_property_groups_api(request: Request, collection_id: int):
|
||||
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: (re)assign properties to collapsible groups in the table header.
|
||||
|
||||
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
|
||||
omitted from any group have their group cleared. Empty group names clear.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
groups = body.get("groups", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -871,12 +871,8 @@ async def set_property_groups_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
async def create_property_api(request: Request, collection_id: int):
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -918,12 +914,8 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
async def update_property_api(request: Request, prop_id: int):
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update a property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -957,7 +949,7 @@ async def update_property_api(request: Request, prop_id: int):
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}/api")
|
||||
async def delete_property_api(request: Request, prop_id: int):
|
||||
def delete_property_api(request: Request, prop_id: int):
|
||||
"""API: delete a property."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -975,12 +967,8 @@ async def delete_property_api(request: Request, prop_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
async def create_relation_property(request: Request, collection_id: int):
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
@@ -1027,12 +1015,8 @@ async def create_relation_property(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
async def link_pages(request: Request, collection_id: int):
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
@@ -1101,12 +1085,8 @@ async def link_pages(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
async def compute_rollup(request: Request):
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
@@ -1127,12 +1107,8 @@ async def compute_rollup(request: Request):
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
async def evaluate_formula(request: Request):
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
@@ -1151,7 +1127,7 @@ async def evaluate_formula(request: Request):
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
async def get_view_api(request: Request, view_id: int):
|
||||
def get_view_api(request: Request, view_id: int):
|
||||
"""API: get a single view config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1161,12 +1137,8 @@ async def get_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
async def update_view_config(request: Request, view_id: int):
|
||||
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1193,12 +1165,8 @@ async def update_view_config(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
async def save_view_as(request: Request, collection_id: int):
|
||||
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: save current view state as a new named view."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
@@ -1225,18 +1193,18 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": new_view_id,
|
||||
"collection_id": collection_id,
|
||||
"name": name,
|
||||
"view_type": view_type,
|
||||
})
|
||||
}))
|
||||
return {"id": new_view_id, "name": name, "view_type": view_type,
|
||||
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
async def list_views_api(request: Request, collection_id: int):
|
||||
def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list views for a collection visible to the current user.
|
||||
|
||||
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
||||
@@ -1261,7 +1229,7 @@ async def list_views_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}/api")
|
||||
async def delete_view_api(request: Request, view_id: int):
|
||||
def delete_view_api(request: Request, view_id: int):
|
||||
"""API: delete a saved view."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1273,12 +1241,8 @@ async def delete_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/duplicate")
|
||||
async def duplicate_view_api(request: Request, view_id: int):
|
||||
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: duplicate a view (config + type), owned by the current user."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1303,12 +1267,12 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": dup_view_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"name": name,
|
||||
"view_type": existing["view_type"],
|
||||
})
|
||||
}))
|
||||
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
||||
"status": "duplicated"}
|
||||
|
||||
@@ -1317,7 +1281,7 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list sub-items of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1328,12 +1292,8 @@ async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: create a sub-item under a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
@@ -1364,23 +1324,23 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"parent_id": page_id,
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
"""API: compute aggregate status from children."""
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
@@ -1404,12 +1364,8 @@ async def aggregate_child_status(request: Request, collection_id: int, page_id:
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
@@ -1431,12 +1387,8 @@ async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
async def check_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
body.get("new_status", "Done")
|
||||
|
||||
@@ -1479,7 +1431,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
|
||||
|
||||
|
||||
@router.get("/{collection_id}/sources/api")
|
||||
async def list_data_sources(request: Request, collection_id: int):
|
||||
def list_data_sources(request: Request, collection_id: int):
|
||||
"""API: list all data sources for a collection."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -1494,12 +1446,8 @@ async def list_data_sources(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/sources/api")
|
||||
async def add_data_source(request: Request, collection_id: int):
|
||||
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: add a data source to a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
source_collection_id = body.get("source_collection_id")
|
||||
if not source_collection_id:
|
||||
@@ -1539,7 +1487,7 @@ async def add_data_source(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/sources/{source_id}/api")
|
||||
async def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
"""API: remove a data source from a collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -1556,15 +1504,11 @@ async def remove_data_source(request: Request, collection_id: int, source_id: in
|
||||
|
||||
|
||||
@router.post("/{collection_id}/linked/api")
|
||||
async def create_linked_database(request: Request, collection_id: int):
|
||||
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a linked database view from a source collection.
|
||||
A linked database copies the structure (views, filters, sorts) of a source
|
||||
but shares the same pages — edits to pages propagate to the source.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
body.get("workspace_id")
|
||||
@@ -1652,7 +1596,7 @@ async def create_linked_database(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/toggle-inline/api")
|
||||
async def toggle_inline(request: Request, collection_id: int):
|
||||
def toggle_inline(request: Request, collection_id: int):
|
||||
"""API: toggle a collection between full-page and inline mode."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
@@ -1677,12 +1621,8 @@ async def toggle_inline(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/inline/api")
|
||||
async def create_inline_database(request: Request):
|
||||
def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -1743,7 +1683,7 @@ async def create_inline_database(request: Request):
|
||||
|
||||
|
||||
@router.put("/{collection_id}/toggle-task/api")
|
||||
async def toggle_task(request: Request, collection_id: int):
|
||||
def toggle_task(request: Request, collection_id: int):
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -1756,7 +1696,7 @@ async def toggle_task(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list dependencies for a page (blocks, blocked_by, related)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1776,12 +1716,8 @@ async def list_page_dependencies(request: Request, collection_id: int, page_id:
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
|
||||
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
dependency_id = body.get("dependency_id")
|
||||
if not dependency_id:
|
||||
raise HTTPException(status_code=400, detail="dependency_id is required")
|
||||
@@ -1804,7 +1740,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
|
||||
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
"""API: remove a dependency."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -1818,15 +1754,11 @@ async def remove_page_dependency(request: Request, collection_id: int, page_id:
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: auto-shift dates based on blocking dependencies."""
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
skip_weekends = body.get("skip_weekends", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -1898,9 +1830,74 @@ 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)
|
||||
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"):
|
||||
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
@@ -1912,9 +1909,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 +2190,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>
|
||||
@@ -2216,14 +2292,14 @@ def _render_chart(view_type: str, collection: dict, pages: list[dict], config: d
|
||||
canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script>
|
||||
<script src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
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 +2307,7 @@ new Chart(document.getElementById('chartCanvas'), {{
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{len(pages)} entries</p>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
@@ -2280,7 +2356,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
|
||||
</form>
|
||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||
</div>
|
||||
<script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
async function submitForm(e) {{
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('collectionForm');
|
||||
@@ -2328,10 +2404,10 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
||||
<script>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
const markers = {markers_json};
|
||||
@@ -2458,7 +2534,7 @@ tr:hover td{{background:#222}}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
async def get_collection_api(request: Request, collection_id: int):
|
||||
def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
@@ -2486,15 +2562,11 @@ async def get_collection_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
async def create_page_api(request: Request, collection_id: int):
|
||||
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
@@ -2535,16 +2607,16 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
+217
-153
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
@@ -52,7 +54,7 @@ def _get_user_or_redirect(request: Request):
|
||||
if count == 0:
|
||||
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_get_user_or_redirect")
|
||||
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
||||
return user
|
||||
|
||||
@@ -88,7 +90,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
avatar_url = row["avatar_url"] or ""
|
||||
avatar_color = row["avatar_color"] or "#3A3A3A"
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = []
|
||||
for repo in repos[:10]:
|
||||
@@ -179,7 +181,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
elif t["provider"] == "github":
|
||||
github_linked = True
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
@@ -193,7 +195,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
@@ -206,7 +208,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
|
||||
shared_made_pages = []
|
||||
@@ -250,12 +252,11 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -278,15 +279,14 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
|
||||
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -308,14 +308,13 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page_root(request: Request, page_id: int):
|
||||
def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -414,17 +413,36 @@ async def view_page_root(request: Request, page_id: int):
|
||||
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
|
||||
page_is_shared = (
|
||||
bool(page.get("is_shared", 0))
|
||||
or page.get("share_mode", "private") != "private"
|
||||
or bool(page.get("published", 0))
|
||||
)
|
||||
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
||||
"page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
"page_is_shared": page_is_shared,
|
||||
"page_data": page_data,
|
||||
"collection_data": collection_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
|
||||
# au lieu des interpolations Jinja — une seule source, même calculs que le
|
||||
# ctx ci-dessus.
|
||||
from app.templating import ENV as _ENV27
|
||||
page_data.update(
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
user_id=_uid or 0,
|
||||
is_shared=page_is_shared,
|
||||
clip_icon=_ENV27.from_string(
|
||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||
).render(),
|
||||
)
|
||||
# Select template: collection pages use database table view
|
||||
if page.get("content_format") == "collection" and not embed:
|
||||
template = env.get_template("page_editor_collection.html")
|
||||
@@ -435,24 +453,27 @@ async def view_page_root(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
|
||||
users = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||
template = env.get_template("accounts.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
@@ -489,6 +510,7 @@ async def help_page(request: Request):
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
<div class="help-page">
|
||||
<div class="help-hero">
|
||||
@@ -590,6 +612,16 @@ FlowDeck supports three authentication methods:<br>
|
||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
@@ -643,10 +675,10 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -678,11 +710,19 @@ def _get_user_id(request: Request) -> int:
|
||||
return user["id"] if user and user.get("id") else 1
|
||||
|
||||
|
||||
def _require_user_id(request: Request) -> int:
|
||||
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
|
||||
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await request.json()
|
||||
def update_profile(request: Request, body: dict = Body(default={})):
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
||||
conn.commit()
|
||||
@@ -690,23 +730,27 @@ async def update_profile(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/user/password")
|
||||
async def update_password(request: Request):
|
||||
from app.password_utils import hash_password
|
||||
body = await request.json()
|
||||
def update_password(request: Request, body: dict = Body(default={})):
|
||||
from app.password_utils import hash_password, verify_password
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
|
||||
current = body.get("current_password", "")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not row or not verify_password(current, row["password_hash"]):
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/user/token")
|
||||
async def generate_token(request: Request):
|
||||
def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _get_user_id(request)
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -718,8 +762,8 @@ async def generate_token(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
async def disconnect_forge(request: Request, provider: str):
|
||||
uid = _get_user_id(request)
|
||||
def disconnect_forge(request: Request, provider: str):
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
||||
@@ -744,14 +788,14 @@ async def dashboard(
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
pass
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
@@ -766,7 +810,7 @@ async def dashboard(
|
||||
).fetchone()
|
||||
has_gitea = bool(tok)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
|
||||
if not has_gitea:
|
||||
# Check if user has any workspace
|
||||
@@ -779,7 +823,7 @@ async def dashboard(
|
||||
# v5.2.0: first-launch → onboarding wizard
|
||||
return RedirectResponse("/welcome", status_code=302)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("dashboard")
|
||||
return RedirectResponse("/local-workspace", status_code=302)
|
||||
|
||||
# ── Gitea user → full dashboard ──
|
||||
@@ -796,8 +840,8 @@ async def dashboard(
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
@@ -807,10 +851,10 @@ async def dashboard(
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
async def workspace_page(request: Request):
|
||||
def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -824,12 +868,12 @@ async def workspace_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
async def gitea_workspace_page(request: Request):
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -890,9 +934,18 @@ async def list_workspace_projects(request: Request):
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
|
||||
counts = {}
|
||||
if rows:
|
||||
for c in conn.execute(
|
||||
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
|
||||
",".join("?" * len(rows))
|
||||
),
|
||||
[r["id"] for r in rows],
|
||||
).fetchall():
|
||||
counts[c["parent_id"]] = c["c"]
|
||||
for r in rows:
|
||||
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
|
||||
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
|
||||
|
||||
gitea_repos = []
|
||||
# Use per-user token if available, otherwise return empty
|
||||
@@ -911,14 +964,13 @@ async def list_workspace_projects(request: Request):
|
||||
"forge": "gitea",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("list_workspace_projects")
|
||||
|
||||
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
||||
|
||||
|
||||
@router.post("/api/workspace/projects")
|
||||
async def create_workspace_project(request: Request):
|
||||
body = await request.json()
|
||||
def create_workspace_project(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -935,7 +987,7 @@ async def create_workspace_project(request: Request):
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
@router.get("/api/workspace/{ws_id:int}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
def list_members(request: Request, ws_id: int):
|
||||
"""List all members of a workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -947,9 +999,8 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/api/workspace/{ws_id:int}/members")
|
||||
async def invite_member(request: Request, ws_id: int):
|
||||
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Invite a user to a workspace by email."""
|
||||
body = await request.json()
|
||||
email = body.get("email", "").strip()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
@@ -970,9 +1021,8 @@ async def invite_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
"""Change a member's role."""
|
||||
body = await request.json()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}
|
||||
@@ -986,7 +1036,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
"""Remove a member from a workspace."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1000,13 +1050,13 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
async def local_workspace_page(request: Request, folder: int = None):
|
||||
def local_workspace_page(request: Request, folder: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1048,7 +1098,7 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tree")
|
||||
async def local_workspace_tree(request: Request, folder: int = None):
|
||||
def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
@@ -1073,7 +1123,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
||||
async def get_page_content(page_id: int):
|
||||
def get_page_content(page_id: int):
|
||||
"""Return the raw content of a page (for preview)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -1111,9 +1161,8 @@ def _file_page_disk_path(page: dict):
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
import os as _os
|
||||
from pathlib import Path
|
||||
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
||||
root = Path(settings.data_dir).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
@@ -1127,9 +1176,21 @@ def _file_page_disk_path(page: dict):
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
def _require_page_view(request: Request, page_id: int) -> None:
|
||||
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1150,13 +1211,15 @@ async def download_page_file(page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
@@ -1176,7 +1239,7 @@ async def page_file_content(page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
async def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
@@ -1344,7 +1407,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
|
||||
|
||||
|
||||
@router.get("/api/nav/menu")
|
||||
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
"""Return the pages at one level for the header breadcrumb navigation menu.
|
||||
|
||||
If ``parent_id`` is given, returns that page's children; otherwise the
|
||||
@@ -1400,9 +1463,8 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
async def create_local_workspace_item(request: Request):
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
@@ -1423,9 +1485,8 @@ async def create_local_workspace_item(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
@@ -1434,20 +1495,20 @@ async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
||||
(datetime.utcnow().isoformat(), item_id),
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1459,12 +1520,11 @@ async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
||||
async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
root = Path(settings.data_dir)
|
||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||
fp = (base_dir / filename).resolve()
|
||||
try:
|
||||
@@ -1480,9 +1540,8 @@ async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
async def move_local_workspace_item(request: Request, item_id: int):
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
body = await request.json()
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1504,7 +1563,8 @@ async def upload_local_workspace_file(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1521,7 +1581,11 @@ async def upload_local_workspace_file(request: Request):
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1542,10 +1606,14 @@ async def upload_local_workspace_file(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await f.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Determine if this is a folder marker or actual file
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
size = len(content)
|
||||
mime = f.content_type or "application/octet-stream"
|
||||
|
||||
@@ -1577,7 +1645,8 @@ async def upload_local_workspace_folder(request: Request):
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
uid = _require_user_id(request) # A22 : pas d'upload anonyme
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
||||
@@ -1599,7 +1668,11 @@ async def upload_local_workspace_folder(request: Request):
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
@@ -1658,9 +1731,13 @@ async def upload_local_workspace_folder(request: Request):
|
||||
counter += 1
|
||||
|
||||
content = await matched.read()
|
||||
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
|
||||
if err:
|
||||
results.append({"name": safe_name, "error": err})
|
||||
continue
|
||||
file_path.write_bytes(content)
|
||||
|
||||
rel_path = str(file_path.relative_to("/data"))
|
||||
rel_path = str(file_path.relative_to(data_root))
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
||||
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
||||
@@ -1708,10 +1785,10 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
async def workspaces_page(request: Request):
|
||||
def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1725,7 +1802,7 @@ async def workspaces_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/workspaces")
|
||||
async def list_workspaces(request: Request):
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -1744,9 +1821,8 @@ async def list_workspaces(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/workspaces")
|
||||
async def create_workspace(request: Request):
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "New Workspace").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1776,9 +1852,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/workspaces/{ws_id:int}")
|
||||
async def rename_workspace(request: Request, ws_id: int):
|
||||
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Rename a workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1789,7 +1864,7 @@ async def rename_workspace(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
async def delete_workspace(request: Request, ws_id: int):
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace and all its pages."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||
@@ -1800,7 +1875,7 @@ async def delete_workspace(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
async def select_workspace(request: Request, ws_id: int):
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
@@ -1811,10 +1886,10 @@ async def select_workspace(request: Request, ws_id: int):
|
||||
# ═══════════ Settings Page ═══════════
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def app_settings_page(request: Request):
|
||||
def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1857,19 +1932,26 @@ async def upload_avatar(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/settings/avatar/{filename:path}")
|
||||
async def serve_avatar_file(filename: str):
|
||||
def serve_avatar_file(filename: str):
|
||||
"""Serve an uploaded avatar image file."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
filepath = Path("/data/avatars") / filename
|
||||
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
|
||||
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
|
||||
base_dir = Path("/data/avatars").resolve()
|
||||
filepath = (base_dir / filename).resolve()
|
||||
try:
|
||||
filepath.relative_to(base_dir)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
||||
if not filepath.is_file():
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
return FileResponse(filepath)
|
||||
|
||||
|
||||
@router.get("/api/avatar/{user_id:int}")
|
||||
async def get_avatar(user_id: int):
|
||||
def get_avatar(user_id: int):
|
||||
"""Redirect to the user's avatar."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
@@ -1879,9 +1961,8 @@ async def get_avatar(user_id: int):
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar-color")
|
||||
async def set_avatar_color(request: Request):
|
||||
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
||||
"""Set the user's avatar background color."""
|
||||
body = await request.json()
|
||||
color = body.get("color", "#3A3A3A")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1895,9 +1976,8 @@ async def set_avatar_color(request: Request):
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
async def create_tag_global(request: Request):
|
||||
def create_tag_global(request: Request, body: dict = Body(default={})):
|
||||
"""Create a tag for the current user."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -1915,9 +1995,8 @@ async def create_tag_global(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
async def update_tag_global(tag_id: int, request: Request):
|
||||
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
body = await request.json()
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
@@ -1929,7 +2008,7 @@ async def update_tag_global(tag_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
||||
async def delete_tag_global(tag_id: int, request: Request):
|
||||
def delete_tag_global(tag_id: int, request: Request):
|
||||
"""Delete a tag — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -1940,7 +2019,7 @@ async def delete_tag_global(tag_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/settings/tags/all")
|
||||
async def list_all_tags_global(request: Request):
|
||||
def list_all_tags_global(request: Request):
|
||||
"""List current user's tags with counts."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -1959,7 +2038,7 @@ async def list_all_tags_global(request: Request):
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
async def list_tags(request: Request):
|
||||
def list_tags(request: Request):
|
||||
"""List ALL user tags with counts scoped to the active workspace."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
@@ -1982,7 +2061,7 @@ async def list_tags(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def get_item_tags(item_id: int):
|
||||
def get_item_tags(item_id: int):
|
||||
"""Get tags for a specific item."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1995,9 +2074,8 @@ async def get_item_tags(item_id: int):
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def add_item_tag(request: Request, item_id: int):
|
||||
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -2025,13 +2103,13 @@ async def add_item_tag(request: Request, item_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_item_tag")
|
||||
|
||||
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
||||
async def remove_item_tag(item_id: int, tag_id: int):
|
||||
def remove_item_tag(item_id: int, tag_id: int):
|
||||
"""Remove a tag from an item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -2043,7 +2121,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tags/search")
|
||||
async def search_by_tags(request: Request, tags: str = ""):
|
||||
def search_by_tags(request: Request, tags: str = ""):
|
||||
"""Search items by tags (comma-separated)."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
@@ -2083,16 +2161,12 @@ async def search_by_tags(request: Request, tags: str = ""):
|
||||
|
||||
# ── Account update ──
|
||||
@router.put("/api/settings/account")
|
||||
async def update_account(request: Request):
|
||||
def update_account(request: Request, body: dict = Body(default={})):
|
||||
"""Update current user's profile: full_name, login, email, password."""
|
||||
from app.password_utils import hash_password
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
uid = user["id"]
|
||||
if "full_name" in body:
|
||||
@@ -2125,15 +2199,14 @@ async def update_account(request: Request):
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
@router.get("/api/sidebar/workspace-tree")
|
||||
async def sidebar_workspace_tree(request: Request):
|
||||
def sidebar_workspace_tree(request: Request):
|
||||
"""Return the sidebar workspace tree as HTML fragment.
|
||||
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -2169,7 +2242,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
@@ -2188,9 +2261,9 @@ async def sidebar_workspace_tree(request: Request):
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2210,7 +2283,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
@@ -2453,7 +2526,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
@router.get("/api/pages/{page_id:int}/content")
|
||||
async def api_page_content(page_id: int):
|
||||
def api_page_content(page_id: int):
|
||||
"""Get page content for side peek preview."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2472,9 +2545,8 @@ async def api_page_content(page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
async def api_rename_page(page_id: int, request: Request):
|
||||
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Inline rename a page title."""
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
@@ -2491,7 +2563,7 @@ async def api_rename_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
async def api_trash_page(page_id: int):
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -2503,17 +2575,13 @@ async def api_trash_page(page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
||||
async def api_convert_to_database(page_id: int, request: Request):
|
||||
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Convert a page into a full-page database (Notion-style).
|
||||
|
||||
Creates a collection linked to this page, adds the default 'Name' property,
|
||||
and sets the page's content_format to 'collection'.
|
||||
"""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
db_name = (body.get("name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -2569,7 +2637,7 @@ async def api_convert_to_database(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id:int}/table-data")
|
||||
async def api_collection_table_data(collection_id: int):
|
||||
def api_collection_table_data(collection_id: int):
|
||||
"""Get collection properties + pages for rendering the table view."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
@@ -2608,13 +2676,9 @@ async def api_collection_table_data(collection_id: int):
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id:int}/pages")
|
||||
async def api_create_collection_page(collection_id: int, request: Request):
|
||||
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new page (row) in a collection."""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "New page").strip() or "New page"
|
||||
icon = body.get("icon", "file")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["emojis"])
|
||||
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _active_ws(request: Request) -> int:
|
||||
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
|
||||
|
||||
|
||||
@router.get("/api/custom-emojis")
|
||||
async def list_custom_emojis(request: Request):
|
||||
def list_custom_emojis(request: Request):
|
||||
"""List the current workspace's custom emojis."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
@@ -62,7 +62,7 @@ async def create_custom_emoji(request: Request):
|
||||
if ext not in _IMAGE_EXTS:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
ws_id = _active_ws(request)
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
||||
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"emoji_{stamp}_{safe}"
|
||||
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/custom-emojis/{emoji_id}")
|
||||
async def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
"""Delete a custom emoji (and its stored file)."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
+19
-9
@@ -23,7 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["export"], prefix="/api/export")
|
||||
|
||||
|
||||
def _load_page_or_404(page_id: int) -> dict:
|
||||
def _load_page_or_404(request: Request, page_id: int) -> dict:
|
||||
"""A16 : session obligatoire + `PermissionManager.can_view_page` — l'export ne
|
||||
doit pas délivrer le contenu d'une page énumérable par id."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
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")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
@@ -31,6 +39,8 @@ def _load_page_or_404(page_id: int) -> dict:
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -51,8 +61,8 @@ def _safe_filename(page: dict, ext: str) -> str:
|
||||
|
||||
|
||||
@router.get("/markdown/{page_id}")
|
||||
async def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
md = page_to_markdown(page)
|
||||
filename = _safe_filename(page, "md")
|
||||
headers = _download_header(filename, "text/markdown")
|
||||
@@ -60,8 +70,8 @@ async def export_markdown(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/html/{page_id}")
|
||||
async def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
html = page_to_standalone_html(page)
|
||||
filename = _safe_filename(page, "html")
|
||||
headers = _download_header(filename, "text/html")
|
||||
@@ -69,8 +79,8 @@ async def export_html(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pdf/{page_id}")
|
||||
async def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
except ImportError:
|
||||
@@ -84,8 +94,8 @@ async def export_pdf(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/site/{page_id}")
|
||||
async def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(page_id)
|
||||
def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
site_bytes = build_static_site_bytes(page)
|
||||
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
||||
filename = f"{title}_site.zip"
|
||||
|
||||
+9
-25
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Gitea integration API routes."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
@@ -19,16 +19,8 @@ def _require_gitea(request: Request):
|
||||
return client
|
||||
|
||||
|
||||
def _require_user_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
if not client:
|
||||
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
|
||||
return client
|
||||
|
||||
|
||||
# ── Orgs ──
|
||||
@router.get("/orgs")
|
||||
async def list_orgs(request: Request):
|
||||
"""List organizations the user belongs to."""
|
||||
@@ -170,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
|
||||
# ── Account linking ──
|
||||
|
||||
@router.get("/status")
|
||||
async def gitea_status(request: Request):
|
||||
def gitea_status(request: Request):
|
||||
"""Check if the current user has Gitea linked."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
@@ -178,7 +170,7 @@ async def gitea_status(request: Request):
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_gitea(request: Request):
|
||||
def disconnect_gitea(request: Request):
|
||||
"""Remove all Gitea OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -194,7 +186,7 @@ async def disconnect_gitea(request: Request):
|
||||
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages")
|
||||
async def list_private_pages(owner: str, repo: str, request: Request):
|
||||
def list_private_pages(owner: str, repo: str, request: Request):
|
||||
"""List private pages for this Gitea project."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -210,7 +202,7 @@ async def list_private_pages(owner: str, repo: str, request: Request):
|
||||
|
||||
|
||||
@router.post("/projects/{owner}/{repo}/private-pages")
|
||||
async def create_private_page(owner: str, repo: str, request: Request):
|
||||
def create_private_page(owner: str, repo: str, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new private page for this Gitea project."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -218,10 +210,6 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "Untitled").strip() or "Untitled"
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -233,7 +221,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Get a single private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -251,7 +239,7 @@ async def get_private_page(owner: str, repo: str, page_id: int, request: Request
|
||||
|
||||
|
||||
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def update_private_page(owner: str, repo: str, page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a private page."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -259,10 +247,6 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "").strip()
|
||||
content = body.get("content", "")
|
||||
with get_conn() as conn:
|
||||
@@ -280,7 +264,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
|
||||
|
||||
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Delete a private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -314,7 +298,7 @@ async def sync_labels(request: Request, owner: str, repo: str):
|
||||
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.db import get_conn
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
gitea = _require_gitea(request)
|
||||
|
||||
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def github_status(request: Request):
|
||||
def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -22,7 +22,7 @@ async def github_status(request: Request):
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_github(request: Request):
|
||||
def disconnect_github(request: Request):
|
||||
"""Remove all GitHub OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""FlowDeck — agent governance API (v7.2.0): policies + approval queue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Body, 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")
|
||||
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")
|
||||
def upsert_policy(request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
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")
|
||||
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")
|
||||
def decide_approval(approval_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
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
|
||||
@@ -39,14 +39,14 @@ def _current_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@page_router.get("/import", response_class=HTMLResponse)
|
||||
async def import_page(request: Request):
|
||||
def import_page(request: Request):
|
||||
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def import_sources(request: Request):
|
||||
def import_sources(request: Request):
|
||||
"""List every available importer for the UI source picker."""
|
||||
return {"sources": list_sources()}
|
||||
|
||||
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
|
||||
|
||||
|
||||
@router.post("/relations/resolve")
|
||||
async def import_resolve_relations(request: Request):
|
||||
def import_resolve_relations(request: Request):
|
||||
"""Convert text columns referencing another collection into relation props."""
|
||||
ws_id, _ = _workspace(request)
|
||||
with get_conn() as conn:
|
||||
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def import_jobs(request: Request):
|
||||
def import_jobs(request: Request):
|
||||
return {"jobs": list_jobs()}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def import_job(job_id: str):
|
||||
def import_job(job_id: str):
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/report")
|
||||
async def import_job_report(job_id: str):
|
||||
def import_job_report(job_id: str):
|
||||
"""Download a job's import report as JSON."""
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
|
||||
+8
-161
@@ -157,7 +157,7 @@ BASE_SELECT = (
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
async def library_recents(
|
||||
def library_recents(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -191,7 +191,7 @@ async def library_recents(
|
||||
|
||||
|
||||
@router.get("/favorites")
|
||||
async def library_favorites(
|
||||
def library_favorites(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -221,7 +221,7 @@ async def library_favorites(
|
||||
|
||||
|
||||
@router.get("/shared")
|
||||
async def library_shared(
|
||||
def library_shared(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -292,7 +292,7 @@ async def library_shared(
|
||||
|
||||
|
||||
@router.get("/published")
|
||||
async def library_published(
|
||||
def library_published(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -317,7 +317,7 @@ async def library_published(
|
||||
|
||||
|
||||
@router.get("/private")
|
||||
async def library_private(
|
||||
def library_private(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -341,76 +341,8 @@ async def library_private(
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace-children/{item_id:int}")
|
||||
async def library_local_workspace_children(item_id: int, request: Request):
|
||||
"""Return children of a local workspace item for tree expansion."""
|
||||
_get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Get the item to find its workspace
|
||||
item = conn.execute(
|
||||
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
|
||||
[item_id],
|
||||
).fetchone()
|
||||
if not item:
|
||||
return {"items": []}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE parent_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[item_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
with get_conn() as conn:
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": "",
|
||||
"workspace": "",
|
||||
"workspace_name": "",
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/children/{page_id:int}")
|
||||
async def library_children(page_id: int, request: Request):
|
||||
def library_children(page_id: int, request: Request):
|
||||
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -426,7 +358,7 @@ async def library_children(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/repository")
|
||||
async def library_repository(
|
||||
def library_repository(
|
||||
request: Request,
|
||||
gitea_owner: str = Query(default=""),
|
||||
gitea_repo: str = Query(default=""),
|
||||
@@ -449,93 +381,8 @@ async def library_repository(
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace")
|
||||
async def library_local_workspace(
|
||||
request: Request,
|
||||
workspace_id: int = Query(default=0),
|
||||
):
|
||||
"""Return local workspace items (files/folders) formatted for Library display."""
|
||||
from app.routers.dashboard import _get_active_workspace
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Get the active workspace
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
if not ws:
|
||||
return {"items": []}
|
||||
|
||||
ws_id = workspace_id or ws["id"]
|
||||
|
||||
# Query local workspace tree
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE workspace_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[ws_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
# Check for children
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": ws.get("name", "Workspace"),
|
||||
"workspace": ws.get("name", ""),
|
||||
"workspace_name": ws.get("name", ""),
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
def _format_size(size_bytes):
|
||||
if not size_bytes:
|
||||
return ""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
if size_bytes < 1048576:
|
||||
return f"{size_bytes/1024:.1f} KB"
|
||||
if size_bytes < 1073741824:
|
||||
return f"{size_bytes/1048576:.1f} MB"
|
||||
return f"{size_bytes/1073741824:.1f} GB"
|
||||
|
||||
|
||||
@router.get("/workspace")
|
||||
async def library_workspace(
|
||||
def library_workspace(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""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, Body, 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")
|
||||
def create_link(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
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")
|
||||
def get_links(request: Request):
|
||||
user = _auth_user(request)
|
||||
return {"links": cal.list_links(user["id"])}
|
||||
|
||||
|
||||
@router.delete("/api/v2/calendar-links/{link_id}")
|
||||
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")
|
||||
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")
|
||||
def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Store a client-side (manual) transcript on an existing row."""
|
||||
_auth_user(request, require_write=True)
|
||||
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
|
||||
@@ -6,10 +6,10 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
</div>"""
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
@@ -118,7 +118,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user.get("login", "admin") if user else "admin"
|
||||
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["notes"], prefix="/notes")
|
||||
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def get_notes(request: Request, owner: str, repo: str):
|
||||
def get_notes(request: Request, owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
|
||||
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
|
||||
).fetchone()
|
||||
content = row["content"] if row else ""
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(request: Request, limit: int = 50):
|
||||
def list_notifications(request: Request, limit: int = 50):
|
||||
"""List the current user's notifications, newest first."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count(request: Request):
|
||||
def unread_count(request: Request):
|
||||
"""Unread count for the topbar badge."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -56,10 +56,9 @@ async def unread_count(request: Request):
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_read(request: Request):
|
||||
def mark_read(request: Request, body: dict = Body(default={})):
|
||||
"""Mark one notification as read (id) or all (id omitted)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
nid = body.get("id")
|
||||
with get_conn() as conn:
|
||||
if nid:
|
||||
@@ -83,7 +82,7 @@ async def mark_all_read(request: Request):
|
||||
|
||||
|
||||
@router.get("/prefs")
|
||||
async def get_prefs(request: Request):
|
||||
def get_prefs(request: Request):
|
||||
"""Return the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
@@ -91,11 +90,10 @@ async def get_prefs(request: Request):
|
||||
|
||||
|
||||
@router.post("/prefs")
|
||||
async def set_prefs(request: Request):
|
||||
def set_prefs(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prefs = notif.get_user_prefs(user["id"])
|
||||
for key in ("comments", "mentions", "reminders", "assignments"):
|
||||
if key in body:
|
||||
@@ -105,7 +103,7 @@ async def set_prefs(request: Request):
|
||||
|
||||
|
||||
@router.get("/timezone")
|
||||
async def get_timezone(request: Request):
|
||||
def get_timezone(request: Request):
|
||||
"""Return the current user's IANA timezone ('' = UTC)."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -116,10 +114,9 @@ async def get_timezone(request: Request):
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(request: Request):
|
||||
def set_timezone(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's IANA timezone (empty string = UTC)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
tz = (body.get("timezone") or "").strip()
|
||||
from app.services.recurrence import is_valid_timezone
|
||||
if tz and not is_valid_timezone(tz):
|
||||
@@ -131,7 +128,7 @@ async def set_timezone(request: Request):
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
_current_user(request)
|
||||
q = (q or "").strip()
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/welcome", response_class=HTMLResponse)
|
||||
async def onboarding_page(request: Request):
|
||||
def onboarding_page(request: Request):
|
||||
"""Onboarding wizard. Redirects logged-out users to login and users who
|
||||
already have a workspace straight to the app."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
|
||||
if ws_count > 0:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("welcome.html")
|
||||
return HTMLResponse(content=template.render(
|
||||
user=user,
|
||||
@@ -63,13 +63,9 @@ def _forge_configured(provider: str) -> bool:
|
||||
|
||||
|
||||
@router.post("/api/onboarding/workspace")
|
||||
async def onboarding_create_workspace(request: Request):
|
||||
def onboarding_create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Step 1 — create the first local workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip() or "My Workspace"
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -90,13 +86,9 @@ async def onboarding_create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/onboarding/project")
|
||||
async def onboarding_create_project(request: Request):
|
||||
def onboarding_create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Step 3 — create the first project: a welcome page in the workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
|
||||
workspace_id = body.get("workspace_id")
|
||||
|
||||
|
||||
+22
-29
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions")
|
||||
async def list_page_permissions(page_id: int, request: Request):
|
||||
def list_page_permissions(page_id: int, request: Request):
|
||||
"""List explicit page grants + the caller's effective role."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
@@ -174,7 +174,7 @@ async def list_page_permissions(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions/mine")
|
||||
async def my_page_permission(page_id: int, request: Request):
|
||||
def my_page_permission(page_id: int, request: Request):
|
||||
"""Effective role of the current user on a page (UI gating)."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
@@ -197,22 +197,20 @@ def _page_type(page_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_id: int, request: Request):
|
||||
def grant_page_permission(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "page", page_id, body,
|
||||
"page_permissions", "page_id", PAGE_ROLES)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions/batch")
|
||||
async def batch_page_permissions(page_id: int, request: Request):
|
||||
def batch_page_permissions(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
grants = body.get("grants") or []
|
||||
if not isinstance(grants, list) or not grants:
|
||||
raise HTTPException(400, "grants must be a non-empty list")
|
||||
@@ -224,7 +222,7 @@ async def batch_page_permissions(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
||||
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
@@ -243,7 +241,7 @@ async def set_page_permission_type(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/permissions")
|
||||
async def list_collection_permissions(collection_id: int, request: Request):
|
||||
def list_collection_permissions(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
@@ -266,17 +264,16 @@ def _collection_type(collection_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_id: int, request: Request):
|
||||
def grant_collection_permission(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "collection", collection_id, body,
|
||||
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
||||
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
@@ -294,7 +291,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/visible")
|
||||
async def visible_properties(collection_id: int, request: Request):
|
||||
def visible_properties(collection_id: int, request: Request):
|
||||
"""Split property ids into visible / hidden for the current user."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
@@ -316,7 +313,7 @@ async def visible_properties(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
@@ -331,11 +328,10 @@ async def list_property_permissions(collection_id: int, property_id: int, reques
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
|
||||
def grant_property_permission(collection_id: int, property_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
@@ -349,7 +345,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
||||
async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
def revoke_property_permission(collection_id: int, property_id: int,
|
||||
perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
@@ -362,16 +358,15 @@ async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(request: Request, workspace_id: int | None = None):
|
||||
def list_groups(request: Request, workspace_id: int | None = None):
|
||||
user = _require_user(request)
|
||||
pm = PermissionManager(user["id"])
|
||||
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(request: Request):
|
||||
def create_group(request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
ws_id = body.get("workspace_id")
|
||||
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
|
||||
created_by=pm.user_id)
|
||||
@@ -382,9 +377,8 @@ async def create_group(request: Request):
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
def update_group(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -405,7 +399,7 @@ async def update_group(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: int, request: Request):
|
||||
def delete_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -422,15 +416,14 @@ async def delete_group(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
async def list_group_members(group_id: int, request: Request):
|
||||
def list_group_members(group_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(group_id: int, request: Request):
|
||||
def add_group_member(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
user_id = body.get("user_id")
|
||||
if not user_id or not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id is required")
|
||||
@@ -451,7 +444,7 @@ async def add_group_member(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -473,7 +466,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
"""Workspace members (+ admins) for the grant pickers."""
|
||||
_require_user(request)
|
||||
q = (q or "").strip().lower()
|
||||
@@ -503,7 +496,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
|
||||
|
||||
|
||||
@router.get("/audit/permissions")
|
||||
async def permission_audit(request: Request, limit: int = 100):
|
||||
def permission_audit(request: Request, limit: int = 100):
|
||||
"""Full permission change history — workspace owner/admin only."""
|
||||
user = _require_user(request)
|
||||
uid = user["id"]
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import projects as projects_svc
|
||||
@@ -22,16 +22,15 @@ def _require_admin(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(request: Request):
|
||||
def list_projects(request: Request):
|
||||
"""List all synced projects (optionally filtered by type)."""
|
||||
proj_type = request.query_params.get("type") or None
|
||||
return {"projects": projects_svc.list_projects(proj_type)}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_project(request: Request):
|
||||
def create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Register a standalone (builtin) project."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
@@ -51,7 +50,7 @@ async def sync_projects(request: Request):
|
||||
|
||||
|
||||
@backups_router.post("/api/settings/backups/run")
|
||||
async def run_backup_now(request: Request):
|
||||
def run_backup_now(request: Request):
|
||||
"""Admin: create a database backup immediately."""
|
||||
_require_admin(request)
|
||||
filename = backup_db()
|
||||
@@ -61,7 +60,7 @@ async def run_backup_now(request: Request):
|
||||
|
||||
|
||||
@backups_router.get("/api/settings/backups")
|
||||
async def admin_list_backups(request: Request):
|
||||
def admin_list_backups(request: Request):
|
||||
"""Admin: list stored backups."""
|
||||
_require_admin(request)
|
||||
return {"backups": list_backups()}
|
||||
|
||||
+14
-22
@@ -55,39 +55,31 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
"""Generate a public API access token.
|
||||
|
||||
When an authenticated session is present the token is bound to that user
|
||||
(revocable from Settings → API tokens); otherwise a legacy shared token is
|
||||
created for backward compatibility.
|
||||
"""
|
||||
def generate_token(request: Request):
|
||||
"""Generate a public API access token (A4 : session obligatoire — plus de
|
||||
« legacy shared token » `user_id=0` créable par un anonymous)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
if user and user.get("id"):
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(user["id"], "API token", _hash_token(token), token[:12]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
(0, token),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
|
||||
(user["id"], "API token", _hash_token(token), token[:12]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@router.get("/collections", dependencies=[Depends(verify_token)])
|
||||
async def public_list_collections(request: Request):
|
||||
def public_list_collections(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_collection(request: Request, collection_id: int):
|
||||
def public_get_collection(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
@@ -100,7 +92,7 @@ async def public_get_collection(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
|
||||
async def public_list_pages(request: Request, collection_id: int):
|
||||
def public_list_pages(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
@@ -110,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_page(request: Request, page_id: int):
|
||||
def public_get_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not p:
|
||||
@@ -119,7 +111,7 @@ async def public_get_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
|
||||
async def public_my_tasks(request: Request):
|
||||
def public_my_tasks(request: Request):
|
||||
"""Public API: list tasks (requires valid token)."""
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
|
||||
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.get("/api/realtime/stats")
|
||||
async def realtime_stats(request: Request):
|
||||
def realtime_stats(request: Request):
|
||||
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
|
||||
|
||||
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
||||
@@ -41,7 +41,7 @@ async def ws_page(websocket: WebSocket, page_id: int):
|
||||
try:
|
||||
await websocket.close(code=4401)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("ws_page")
|
||||
return
|
||||
|
||||
conn = await manager.connect(websocket, page_id, user)
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""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, Body, 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")
|
||||
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")
|
||||
def scim_create(request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
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}")
|
||||
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}")
|
||||
def scim_replace(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
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}")
|
||||
def scim_patch(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
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}")
|
||||
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")
|
||||
def create_scim_token(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
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")
|
||||
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}")
|
||||
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")
|
||||
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")
|
||||
def create_domain(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
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}")
|
||||
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}
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search")
|
||||
async def search(request: Request, q: str = Query(default="")):
|
||||
def search(request: Request, q: str = Query(default="")):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user_id = user.get("id") if user and user.get("id") else None
|
||||
|
||||
|
||||
@@ -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")
|
||||
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")
|
||||
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")}
|
||||
@@ -8,7 +8,7 @@ import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
async def list_tokens(request: Request):
|
||||
def list_tokens(request: Request):
|
||||
"""List the current user's API tokens (prefix only, no secrets)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -45,10 +45,9 @@ async def list_tokens(request: Request):
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_token(request: Request):
|
||||
def create_token(request: Request, body: dict = Body(default={})):
|
||||
"""Create an API token for the current user. The secret is returned once."""
|
||||
uid = _current_user_id(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "API token"
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
@@ -63,7 +62,7 @@ async def create_token(request: Request):
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id:int}")
|
||||
async def revoke_token(token_id: int, request: Request):
|
||||
def revoke_token(token_id: int, request: Request):
|
||||
"""Revoke an API token (soft delete)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -81,7 +80,7 @@ async def revoke_token(token_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(request: Request):
|
||||
def list_sessions(request: Request):
|
||||
"""List the current user's active sessions with their devices."""
|
||||
uid = _current_user_id(request)
|
||||
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -101,7 +100,7 @@ async def list_sessions(request: Request):
|
||||
|
||||
|
||||
@router.post("/sessions/{sid}/revoke")
|
||||
async def revoke_session(sid: str, request: Request):
|
||||
def revoke_session(sid: str, request: Request):
|
||||
"""Revoke an active session. If it's the current one, the user is logged out."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -117,5 +116,5 @@ async def revoke_session(sid: str, request: Request):
|
||||
try:
|
||||
request.session.clear()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("revoke_session")
|
||||
return {"status": "revoked"}
|
||||
|
||||
+19
-74
@@ -2,15 +2,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -24,22 +24,13 @@ def _require_auth(request: Request) -> dict:
|
||||
return user
|
||||
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Generate a URL-safe slug from a page title."""
|
||||
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
# ── Page Sharing ──
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
def share_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Invite a user, an email, or a group to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
target_group_id = body.get("group_id")
|
||||
email = body.get("email", "")
|
||||
@@ -128,9 +119,9 @@ async def share_page(page_id: int, request: Request):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("share_page")
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
@@ -173,11 +164,10 @@ def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
def update_share_permission(page_id: int, share_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
@@ -213,7 +203,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/share/{share_id}")
|
||||
async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
def remove_share(page_id: int, share_id: int, request: Request):
|
||||
"""Remove a share invitation."""
|
||||
_require_auth(request)
|
||||
|
||||
@@ -247,7 +237,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/shares")
|
||||
async def list_shares(page_id: int, request: Request):
|
||||
def list_shares(page_id: int, request: Request):
|
||||
"""Get all shares for a page."""
|
||||
_require_auth(request)
|
||||
|
||||
@@ -302,38 +292,11 @@ async def list_shares(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
slug = _slugify(page["title"])
|
||||
# Ensure uniqueness by appending suffix if needed
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while conn.execute(
|
||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||
).fetchone():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
slug, _title = publish(page_id)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -343,28 +306,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page(page_id: int, request: Request):
|
||||
def unpublish_page(page_id: int, request: Request):
|
||||
"""Unpublish a page."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
@@ -376,10 +322,9 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/recents/track")
|
||||
async def track_recent(request: Request):
|
||||
def track_recent(request: Request, body: dict = Body(default={})):
|
||||
"""Record a page access in recents."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
workspace = body.get("workspace", "")
|
||||
source_type = body.get("source_type", "local")
|
||||
@@ -399,7 +344,7 @@ async def track_recent(request: Request):
|
||||
DO UPDATE SET workspace=excluded.workspace,
|
||||
source_type=excluded.source_type,
|
||||
accessed_at=excluded.accessed_at""",
|
||||
(user["id"], page_id, workspace, source_type, datetime.utcnow().isoformat()),
|
||||
(user["id"], page_id, workspace, source_type, datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -407,5 +352,5 @@ async def track_recent(request: Request):
|
||||
"status": "tracked",
|
||||
"user_id": user["id"],
|
||||
"page_id": page_id,
|
||||
"accessed_at": datetime.utcnow().isoformat(),
|
||||
"accessed_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -27,14 +27,15 @@ 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},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_sidebar_config(request: Request):
|
||||
def get_sidebar_config(request: Request):
|
||||
"""Get the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -80,13 +81,9 @@ def get_sidebar_config_sync(user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def save_sidebar_config(request: Request):
|
||||
def save_sidebar_config(request: Request, body: dict = Body(...)):
|
||||
"""Save the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
config = body.get("config")
|
||||
if not config or not isinstance(config, dict):
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
"""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 logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Body, 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,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
logger.exception("_render_page_html")
|
||||
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:
|
||||
logger.exception("_track_view")
|
||||
|
||||
|
||||
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")
|
||||
def create_site(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
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")
|
||||
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}")
|
||||
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}")
|
||||
def update_site(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
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")
|
||||
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}")
|
||||
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")
|
||||
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")
|
||||
def add_site_page(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
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}")
|
||||
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")
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
logger.exception("public_site_auth")
|
||||
password = ""
|
||||
else:
|
||||
try:
|
||||
form = await request.form()
|
||||
password = form.get("password", "")
|
||||
except Exception:
|
||||
logger.exception("public_site_auth")
|
||||
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")
|
||||
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")
|
||||
def put_form_config(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
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)
|
||||
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:
|
||||
logger.exception("submit_form")
|
||||
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:
|
||||
logger.exception("submit_form")
|
||||
try:
|
||||
from app.services.automations import fire_event as _fire
|
||||
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
|
||||
except Exception:
|
||||
logger.exception("submit_form")
|
||||
if "application/json" in ctype:
|
||||
return {"status": "ok", "row_id": row_id,
|
||||
"message": cfg.get("success_message") or "Merci !"}
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<p>{html.escape(cfg.get('success_message') or 'Merci !')}</p></body></html>"""
|
||||
)
|
||||
|
||||
|
||||
# used by tests to reset the anonymous rate limiter
|
||||
def _reset_form_rate() -> None:
|
||||
_form_rate.clear()
|
||||
|
||||
|
||||
# Backwards-compat alias for tests importing ``get_bearer_user`` from here.
|
||||
__all__ = ["router", "get_bearer_user"]
|
||||
@@ -0,0 +1,652 @@
|
||||
"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API.
|
||||
|
||||
Two families of routes:
|
||||
|
||||
* ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect,
|
||||
ACS callback, SP metadata, Single Logout). The callback endpoints are
|
||||
CSRF-exempt (cross-site POST from the IdP) and instead protected by the
|
||||
single-use ``sso_requests`` relay token + full assertion validation.
|
||||
* ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token
|
||||
with write scope), consumed by Settings → Admin → SSO / Enterprise.
|
||||
|
||||
Every attempt — success or rejection — lands in ``sso_login_history``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.providers import oidc_provider, saml_provider
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import sso_provisioning as sso
|
||||
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sso"])
|
||||
|
||||
DEFAULT_NEXT = "/workspaces"
|
||||
|
||||
# ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ──────────────
|
||||
_RATE_WINDOW = 60.0
|
||||
_RATE_MAX = 5
|
||||
_rate_store: dict[str, tuple[float, int]] = {}
|
||||
|
||||
|
||||
def _rate_ok(request: Request, bucket: str = "sso") -> bool:
|
||||
from app.config import settings
|
||||
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
key = f"{bucket}:{ip}"
|
||||
now = time.time()
|
||||
window, count = _rate_store.get(key, (0.0, 0))
|
||||
if now - window > _RATE_WINDOW:
|
||||
_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= _RATE_MAX:
|
||||
return False
|
||||
_rate_store[key] = (window, count + 1)
|
||||
return True
|
||||
|
||||
|
||||
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
|
||||
"""Small standalone error/info page (same styling as the login page)."""
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>FlowDeck — {title}</title><style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
|
||||
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
|
||||
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
|
||||
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
|
||||
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
|
||||
def _sso_config_or_error() -> dict | None:
|
||||
cfg = sso.get_sso_config()
|
||||
return sso.normalize_config(cfg) if cfg else None
|
||||
|
||||
|
||||
def _session_cookie(user_data: dict, request: Request):
|
||||
"""Signed, revocable session cookie (same shape as local/OAuth logins)."""
|
||||
return SessionManager.create_session(user_data, request)
|
||||
|
||||
|
||||
def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse:
|
||||
provider_type = (cfg or {}).get("provider_type", "saml")
|
||||
sso.log_sso_login(
|
||||
user_id=None,
|
||||
provider_type=provider_type,
|
||||
provider_name=(cfg or {}).get("name") or "SSO",
|
||||
identifier=identifier,
|
||||
request=request,
|
||||
success=False,
|
||||
error=message,
|
||||
)
|
||||
logger.warning("SSO login rejected: %s", message)
|
||||
safe = (
|
||||
message.replace("&", "&").replace("<", "<").replace(">", ">")[:400]
|
||||
)
|
||||
return _page(
|
||||
"SSO sign-in failed",
|
||||
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════ SAML 2.0 ════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/saml/login")
|
||||
def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
|
||||
if not _rate_ok(request, "saml"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page(
|
||||
"SAML not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
# RelayState = "<AuthnRequest id>.<CSRF token>" — both checked at the ACS.
|
||||
csrf_token = secrets.token_hex(16)
|
||||
# The id is only known after building the request, so build it first with a
|
||||
# placeholder relay state, then re-issue with the real one? python3-saml
|
||||
# builds the AuthnRequest inside login(); we instead create the row right
|
||||
# after login() returns the URL — but the RelayState is already embedded.
|
||||
# So: generate the request id ourselves is not possible → build the URL,
|
||||
# then patch the RelayState by rebuilding with the known id.
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
provisional = saml_provider.create_login(request, cfg, relay_state="_pending_")
|
||||
authn_id = provisional[1]
|
||||
relay = f"{authn_id}.{csrf_token}"
|
||||
sso.create_request(
|
||||
"saml_authn",
|
||||
request_id=authn_id,
|
||||
relay_state=csrf_token,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
# Replace the placeholder RelayState with the real token (same SAMLRequest).
|
||||
parsed = urlparse(provisional[0])
|
||||
params = parse_qs(parsed.query)
|
||||
params["RelayState"] = [relay]
|
||||
flat = [(k, v) for k, values in params.items() for v in values]
|
||||
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}"
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/auth/saml/callback")
|
||||
async def saml_callback(request: Request):
|
||||
"""Assertion Consumer Service — validate the SAMLResponse and open a session."""
|
||||
if not _rate_ok(request, "saml-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
form = await request.form()
|
||||
saml_response = str(form.get("SAMLResponse") or "")
|
||||
relay_state = str(form.get("RelayState") or "")
|
||||
if not saml_response:
|
||||
return _login_error("Missing SAMLResponse", cfg=None, request=request)
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _login_error("SAML is not configured", cfg=None, request=request)
|
||||
|
||||
authn_id, _, csrf_token = relay_state.partition(".")
|
||||
pending = sso.peek_request("saml_authn", authn_id)
|
||||
if not pending and sso.was_consumed("saml_authn", authn_id):
|
||||
# Same assertion twice: the single-use row is already spent.
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
if not pending or not csrf_token or not secrets.compare_digest(
|
||||
pending.get("relay_state", ""), csrf_token
|
||||
):
|
||||
return _login_error(
|
||||
"Unknown or expired login request (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
try:
|
||||
identity = saml_provider.process_response(
|
||||
request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id
|
||||
)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request)
|
||||
|
||||
# Single-use: the same AuthnRequest id can never authenticate twice.
|
||||
consumed = sso.consume_request("saml_authn", authn_id, csrf_token)
|
||||
if not consumed:
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
|
||||
claims = sso.identity_from_saml(identity, cfg)
|
||||
identifier = sso.sso_identifier_field(claims)
|
||||
try:
|
||||
user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="saml",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
user_data = dict(user)
|
||||
user_data["_sso_name_id"] = identity.name_id
|
||||
user_data["_sso_session_index"] = identity.session_index
|
||||
response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(user_data, request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/saml/metadata")
|
||||
def saml_metadata(request: Request):
|
||||
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page("SAML not configured", "<p>No SAML configuration found.</p>", status=404)
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
try:
|
||||
xml = saml_provider.metadata_xml(request, cfg)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _page("Metadata error", f"<p>{err}</p>", status=500)
|
||||
return HTMLResponse(xml, media_type="application/samlmetadata+xml")
|
||||
|
||||
|
||||
async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Single Logout: SP-initiated (our logout button) or IdP-initiated.
|
||||
|
||||
* no SAML payload → build a LogoutRequest to the IdP (after revoking the
|
||||
local session);
|
||||
* ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse
|
||||
of our own SLO, or a LogoutRequest issued by the IdP).
|
||||
"""
|
||||
form = dict(await request.form()) if request.method == "POST" else {}
|
||||
query = dict(request.query_params)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse")
|
||||
if payload:
|
||||
try:
|
||||
url, errors = saml_provider.process_slo_form(request, cfg, form, query)
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SLO processing failed: %s", err)
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
if errors:
|
||||
return _login_error(
|
||||
"; ".join(errors)[:300], cfg=cfg, request=request
|
||||
)
|
||||
response = RedirectResponse(url or next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
# SP-initiated
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(cookie) if cookie else None
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if user and cfg.get("slo_url") and user.get("_sso_name_id"):
|
||||
try:
|
||||
logout_url = saml_provider.build_logout_url(
|
||||
request, cfg,
|
||||
return_to=sso.safe_next_path(next),
|
||||
name_id=user.get("_sso_name_id", ""),
|
||||
session_index=user.get("_sso_session_index", ""),
|
||||
)
|
||||
# Keep the cookie-clearing headers built above: hand the browser
|
||||
# to the IdP with our local session already dead.
|
||||
response = RedirectResponse(logout_url, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SP-initiated SLO failed: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/saml/logout")
|
||||
async def saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""SP-initiated Single Logout (GET) — hands the browser to the IdP."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/saml/logout")
|
||||
async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse)."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
# ═════════════════════════════════ OIDC ═══════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/oidc/login")
|
||||
async def oidc_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""Redirect to the OIDC provider (authorization code + PKCE)."""
|
||||
if not _rate_ok(request, "oidc"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _page(
|
||||
"OIDC not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
nonce = secrets.token_hex(16)
|
||||
verifier, challenge = oidc_provider.pkce_pair()
|
||||
sso.create_request(
|
||||
"oidc",
|
||||
request_id=state,
|
||||
relay_state=nonce,
|
||||
code_verifier=verifier,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
url = oidc_provider.build_authorize_url(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
scope=cfg.get("scope") or "openid profile email",
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge=challenge,
|
||||
)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
async def _oidc_callback(request: Request):
|
||||
"""OIDC callback: exchange the code, validate the ID token, open a session."""
|
||||
if not _rate_ok(request, "oidc-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
params = dict(request.query_params)
|
||||
if request.method == "POST":
|
||||
params.update({k: str(v) for k, v in (await request.form()).items()})
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _login_error("OIDC is not configured", cfg=None, request=request)
|
||||
|
||||
if params.get("error"):
|
||||
return _login_error(
|
||||
f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(),
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
code, state = params.get("code", ""), params.get("state", "")
|
||||
pending = sso.consume_request("oidc", state)
|
||||
if not code or not pending:
|
||||
return _login_error(
|
||||
"Unknown or expired OIDC state (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
tokens = await oidc_provider.exchange_code(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
client_secret=sso.client_secret_value(cfg),
|
||||
code=code,
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
code_verifier=pending.get("code_verifier", ""),
|
||||
)
|
||||
jwks = await _fetch_jwks(doc)
|
||||
claims = oidc_provider.validate_id_token(
|
||||
tokens.get("id_token", ""),
|
||||
issuer=cfg["issuer_url"],
|
||||
client_id=cfg["client_id"],
|
||||
nonce=pending.get("relay_state", ""),
|
||||
jwks=jwks,
|
||||
)
|
||||
userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", ""))
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=state, request=request)
|
||||
|
||||
merged = {**claims, **userinfo}
|
||||
identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None)
|
||||
identifier = sso.sso_identifier_field(identity)
|
||||
try:
|
||||
user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="oidc",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(dict(user), request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
async def oidc_callback(request: Request):
|
||||
"""OIDC callback (GET, authorization code in the query string)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/callback")
|
||||
async def oidc_callback_post(request: Request):
|
||||
"""OIDC callback (POST, form_post response mode)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
async def _fetch_jwks(doc: dict) -> dict:
|
||||
url = doc.get("jwks_uri")
|
||||
if not url:
|
||||
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as err:
|
||||
raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err
|
||||
if not isinstance(data, dict) or not data.get("keys"):
|
||||
raise oidc_provider.OIDCError("Issuer JWKS contains no keys")
|
||||
return data
|
||||
|
||||
|
||||
async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Local logout + RP-initiated logout at the provider when supported."""
|
||||
cfg = _sso_config_or_error()
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if cfg and cfg["provider_type"] == "oidc":
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
end_session = doc.get("end_session_endpoint")
|
||||
if end_session:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
qs = urlencode({
|
||||
"client_id": cfg["client_id"],
|
||||
"post_logout_redirect_uri": external_base_url(request) + next,
|
||||
})
|
||||
sep = "&" if "?" in end_session else "?"
|
||||
return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302)
|
||||
except oidc_provider.OIDCError as err:
|
||||
logger.debug("RP-initiated logout skipped: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/logout")
|
||||
async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (GET) — local session first, then the IdP end-session URL."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/logout")
|
||||
async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (POST)."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
# ═══════════════════════ Admin configuration API ══════════════════════════
|
||||
|
||||
|
||||
def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
"""Admin identity: Bearer token (scope read/write) or an admin session.
|
||||
|
||||
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
|
||||
exempted in the middleware, so the check lives here for this router.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth_header[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
scopes = user.get("_token_scopes") or ""
|
||||
need = "write" if write else "read"
|
||||
if not (has_scope(scopes, need) or has_scope(scopes, "admin")):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, login, full_name, email, is_admin FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
cookie = request.cookies.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
if not cookie or not header or not secrets.compare_digest(cookie, header):
|
||||
raise HTTPException(status_code=403, detail="CSRF validation failed")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/providers")
|
||||
def sso_providers(request: Request):
|
||||
"""Public: what the login page should show (button list + sso_only flag)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg:
|
||||
return {"providers": [], "sso_only": False}
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
base = external_base_url(request)
|
||||
login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login"
|
||||
return {
|
||||
"providers": [{
|
||||
"type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"icon": "🏢",
|
||||
"login_url": f"{login_path}?next={DEFAULT_NEXT}",
|
||||
}],
|
||||
"sso_only": bool(cfg.get("sso_only")),
|
||||
"base_url": base,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/config")
|
||||
def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
_require_admin(request, write=False)
|
||||
cfg = _sso_config_or_error()
|
||||
return sso.public_config_view(cfg)
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/config")
|
||||
@router.put("/api/v2/sso/config")
|
||||
def save_sso_config_api(request: Request, payload: dict = Body(...)):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
|
||||
except sso.SSOConfigError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0),
|
||||
f"provider={saved.get('provider_type')}", request)
|
||||
return sso.public_config_view(saved)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sso/config")
|
||||
def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = _require_admin(request, write=True)
|
||||
removed = sso.delete_sso_config()
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.disable", "sso_config", 0, "", request)
|
||||
return {"status": "ok", "disabled": removed}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/workspaces")
|
||||
def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces ORDER BY name"
|
||||
).fetchall()
|
||||
cfg = _sso_config_or_error()
|
||||
return {
|
||||
"workspaces": [dict(r) for r in rows],
|
||||
"default_workspace_id": (cfg or {}).get("default_workspace_id"),
|
||||
"sso_only": bool((cfg or {}).get("sso_only")),
|
||||
"provisioned_users": sso.provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/sync")
|
||||
def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
result = sso.force_sync_all_groups()
|
||||
except sso.SSOProvisioningError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.sync", "sso_config", 0, str(result), request)
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/history")
|
||||
def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name,
|
||||
h.sso_identifier, h.ip_address, h.success, h.error_message,
|
||||
h.created_at
|
||||
FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id
|
||||
ORDER BY h.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ident = d.get("sso_identifier") or ""
|
||||
if "|" in ident: # drop the stored group list from the UI payload
|
||||
d["sso_identifier"] = ident.split("|", 1)[0]
|
||||
d["success"] = bool(d["success"])
|
||||
out.append(d)
|
||||
return {"history": out}
|
||||
+1
-1
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
|
||||
+12
-21
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -88,7 +88,7 @@ def _require_user(request: Request) -> dict:
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
@@ -101,12 +101,8 @@ async def clipper_status(request: Request):
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
def auth_verify(request: Request, body: dict = Body(default={})):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
|
||||
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
|
||||
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
|
||||
@@ -124,7 +120,7 @@ async def auth_verify(request: Request):
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
def clip_page(request: Request, body: dict = Body(...)):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
@@ -134,10 +130,6 @@ async def clip_page(request: Request):
|
||||
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# Device identification for rate limiting and logging
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
|
||||
@@ -179,7 +171,7 @@ async def clip_page(request: Request):
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("clip_page")
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
@@ -203,20 +195,20 @@ async def clip_page(request: Request):
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("clip_page")
|
||||
|
||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async def list_extension_devices(request: Request):
|
||||
def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async def revoke_extension_device(device_id: int, request: Request):
|
||||
def revoke_extension_device(device_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
@@ -227,12 +219,11 @@ async def revoke_extension_device(device_id: int, request: Request):
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
def extensions_page(request: Request):
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
@@ -250,7 +241,7 @@ async def extensions_page(request: Request):
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("extensions_page")
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""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, Body, 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")
|
||||
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")
|
||||
def register_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_registration_response
|
||||
user = _session_user(request)
|
||||
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")
|
||||
def login_begin(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_authentication_options, options_to_json
|
||||
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")
|
||||
def login_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_authentication_response
|
||||
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")
|
||||
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}")
|
||||
def delete_key(key_id: int, request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
||||
(key_id, user["id"]))
|
||||
conn.commit()
|
||||
if not cur.rowcount:
|
||||
raise HTTPException(404, "Key not found")
|
||||
return {"status": "deleted", "id": key_id}
|
||||
|
||||
|
||||
def _b64url_to_bytes(data: str) -> bytes:
|
||||
import base64
|
||||
padded = data + "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def reset_challenges() -> None:
|
||||
_challenges.clear()
|
||||
|
||||
|
||||
__all__ = ["router", "reset_challenges", "secrets"]
|
||||
@@ -0,0 +1,513 @@
|
||||
"""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, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
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
|
||||
from app.templating import ENV
|
||||
|
||||
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")
|
||||
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)
|
||||
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 = ENV
|
||||
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")
|
||||
def create_teamspace(request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
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}")
|
||||
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")
|
||||
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}")
|
||||
def set_member(teamspace_id: int, member_id: int, request: Request, body: dict = Body(default={})):
|
||||
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")
|
||||
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}")
|
||||
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")
|
||||
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")
|
||||
def verify_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
def react(comment_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
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")
|
||||
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")
|
||||
def create_guest(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
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:
|
||||
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")
|
||||
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}")
|
||||
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)
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
def preview_blocks(request: Request, body: dict = Body(default={})):
|
||||
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
|
||||
_user(request)
|
||||
blocks = body.get("blocks")
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(400, "blocks must be a list")
|
||||
if len(blocks) > 200:
|
||||
raise HTTPException(400, "max 200 blocks per preview")
|
||||
from app.services.wiki_blocks import mmdc_available, render_block
|
||||
out = [{"type": b.get("type"), "html": render_block(b)} for b in blocks
|
||||
if isinstance(b, dict) and b.get("type") in ("mermaid", "equation_inline", "progress")]
|
||||
return {"rendered": out, "mmdc_available": mmdc_available()}
|
||||
@@ -0,0 +1,208 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, 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")
|
||||
def create_worker(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
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")
|
||||
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}")
|
||||
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}")
|
||||
def update_worker(worker_id: int, request: Request, body: dict = Body(default={})):
|
||||
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 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}")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")}
|
||||
+118
-83
@@ -2,17 +2,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -25,18 +26,38 @@ def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""A15 : webhooks sortants = admin — le serveur POSTe le contenu des pages."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
|
||||
def _require_ws_admin(request: Request, ws_id: int) -> None:
|
||||
"""A5 — CRUD des membres : session obligatoire + rôle admin de l'espace
|
||||
(ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se
|
||||
promouvoir admin."""
|
||||
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:
|
||||
if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone():
|
||||
return
|
||||
row = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(ws_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row or row["role"] != "admin":
|
||||
raise HTTPException(403, "Workspace admin role required")
|
||||
|
||||
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
|
||||
return {"workspaces": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "Default Workspace")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
@@ -57,7 +78,9 @@ async def create_workspace(request: Request):
|
||||
# ── Members ──
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
def list_members(request: Request, ws_id: int):
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
|
||||
@@ -67,14 +90,14 @@ async def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)",
|
||||
(user_id, f"user_{user_id}", f"User {user_id}"))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
|
||||
(ws_id, user_id, role))
|
||||
@@ -83,8 +106,8 @@ async def add_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
@@ -96,7 +119,8 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
conn.commit()
|
||||
@@ -106,7 +130,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
# ── Comments ──
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
def list_comments(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
|
||||
@@ -116,8 +140,7 @@ async def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body", "").strip()
|
||||
if not b:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -131,15 +154,14 @@ async def add_comment(request: Request, page_id: int):
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_comment")
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
@@ -151,16 +173,16 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if resolved and row and not int(row["resolved"] or 0):
|
||||
try:
|
||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_comment")
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
# ── Page History ──
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
async def page_history(request: Request, page_id: int):
|
||||
def page_history(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
||||
@@ -170,8 +192,7 @@ async def page_history(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history")
|
||||
async def record_history(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def record_history(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
@@ -187,7 +208,7 @@ async def record_history(request: Request, page_id: int):
|
||||
# ── Favorites ──
|
||||
|
||||
@router.get("/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
@@ -203,8 +224,7 @@ async def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
page_id = body.get("page_id")
|
||||
@@ -217,14 +237,14 @@ async def add_favorite(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
async def remove_favorite(request: Request, fav_id: int):
|
||||
def remove_favorite(request: Request, fav_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
conn.commit()
|
||||
@@ -234,15 +254,14 @@ async def remove_favorite(request: Request, fav_id: int):
|
||||
# ── Templates ──
|
||||
|
||||
@router.get("/templates/database")
|
||||
async def list_db_templates(request: Request):
|
||||
def list_db_templates(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/database")
|
||||
async def create_db_template(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_db_template(request: Request, body: dict = Body(default={})):
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
@@ -255,9 +274,8 @@ async def create_db_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
def apply_db_template(request: Request, tid: int, body: dict = Body(default={})):
|
||||
from app.services.db_templates import create_from_template
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "New Database")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||
@@ -269,7 +287,7 @@ async def apply_db_template(request: Request, tid: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
async def list_page_templates(request: Request, collection_id: int):
|
||||
def list_page_templates(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||
@@ -278,8 +296,7 @@ async def list_page_templates(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page")
|
||||
async def create_page_template(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_page_template(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
|
||||
@@ -290,8 +307,7 @@ async def create_page_template(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
|
||||
async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def apply_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
|
||||
if not tmpl:
|
||||
@@ -304,19 +320,18 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"title": body.get("title", "New Page"),
|
||||
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
|
||||
})
|
||||
}))
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
def update_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
"""Update a page template — name, properties, content, recurrence."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||
@@ -342,7 +357,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
"""Delete a page template."""
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
@@ -358,7 +373,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
# ── v4.2.0: Dashboards ──
|
||||
|
||||
@router.get("/collections/{collection_id}/dashboards")
|
||||
async def list_dashboards(request: Request, collection_id: int):
|
||||
def list_dashboards(request: Request, collection_id: int):
|
||||
"""List all dashboards for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -368,9 +383,8 @@ async def list_dashboards(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
async def create_dashboard(request: Request, collection_id: int):
|
||||
def create_dashboard(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new dashboard for a collection."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "Dashboard").strip()
|
||||
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
|
||||
|
||||
@@ -387,9 +401,8 @@ async def create_dashboard(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/dashboards/{did}")
|
||||
async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
def update_dashboard(request: Request, collection_id: int, did: int, body: dict = Body(default={})):
|
||||
"""Update a dashboard — name or layout (widgets grid)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||
@@ -409,7 +422,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/dashboards/{did}")
|
||||
async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
"""Delete a dashboard."""
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
@@ -425,7 +438,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
# ── v4.5.0: Sprints ──
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
async def list_sprints(request: Request, collection_id: int):
|
||||
def list_sprints(request: Request, collection_id: int):
|
||||
"""List all sprints for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -444,9 +457,8 @@ async def list_sprints(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint(request: Request, collection_id: int):
|
||||
def create_sprint(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "").strip()
|
||||
start_date = body.get("start_date", "")
|
||||
end_date = body.get("end_date", "")
|
||||
@@ -464,16 +476,15 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("create_sprint")
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/sprints/{sid}")
|
||||
async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
def update_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Update a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
@@ -494,14 +505,14 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
run_event_sync(fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}))
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("update_sprint")
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}")
|
||||
async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Delete a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
@@ -515,9 +526,8 @@ async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
def assign_page_to_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Assign a page to a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
@@ -545,7 +555,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
|
||||
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
"""Remove a page from a sprint."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -559,7 +569,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
||||
async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
"""Calculate burndown data for a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
@@ -607,8 +617,7 @@ async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def import_csv(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
csv_data = body.get("csv", "")
|
||||
if not csv_data:
|
||||
raise HTTPException(400, "csv field required")
|
||||
@@ -633,7 +642,7 @@ async def import_csv(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
async def export_csv(request: Request, collection_id: int):
|
||||
def export_csv(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||
@@ -663,20 +672,28 @@ async def export_csv(request: Request, collection_id: int):
|
||||
# ── Webhooks Outbound Management ──
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
return {"webhooks": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_webhook(request: Request, body: dict = Body(default={})):
|
||||
_require_admin(request)
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
# A15 : SSRF — le scheduler POSTe le contenu des pages vers cette URL.
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise HTTPException(400, f"url non autorisée: {parsed.hostname}")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
|
||||
@@ -687,7 +704,8 @@ async def create_webhook(request: Request):
|
||||
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
def delete_webhook(request: Request, wh_id: int):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
conn.commit()
|
||||
@@ -697,23 +715,40 @@ async def delete_webhook(request: Request, wh_id: int):
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required."""
|
||||
def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
||||
masquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké
|
||||
sur le titre de la base ou d'une ligne).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
ptype = coll["permission_type"] if "permission_type" in coll.keys() else "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
# 404 explicite : le handler global transformerait un HTTPException(404)
|
||||
# en redirection 302 → login pour un chemin HTML.
|
||||
return HTMLResponse(
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title></head>"
|
||||
"<body><h1>404 — Not found</h1></body></html>",
|
||||
status_code=404,
|
||||
)
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
esc = html.escape
|
||||
name = esc(str(coll["name"] or ""))
|
||||
icon = esc(str(coll["icon"] or ""))
|
||||
items = "".join(
|
||||
f"<li>{p['icon']} <b>{p['title']}</b></li>"
|
||||
f"<li>{esc(str(p['icon'] or ''))} <b>{esc(str(p['title'] or ''))}</b></li>"
|
||||
for p in pages
|
||||
)
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
|
||||
<html><head><meta charset="utf-8"><title>{name} — FlowDeck Public</title>
|
||||
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
|
||||
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
|
||||
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
<body><h1>{icon} {name}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
|
||||
|
||||
@@ -18,15 +18,30 @@ 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__)
|
||||
|
||||
MAX_ITERATIONS = 12
|
||||
|
||||
|
||||
async def _fire_agent_webhook(event: str, payload: dict) -> None:
|
||||
"""Dispatch an outbound agent lifecycle event (never raises).
|
||||
|
||||
Lifecycle: ``agent.run.started`` → ``agent.run.finished`` | ``agent.run.failed``.
|
||||
All three are in the webhook_outbound catalogue, so integrations can subscribe
|
||||
to `agent.*` and drive FlowDeck Agent from outside (Agent phase 5).
|
||||
"""
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("%s webhook dispatch failed", event)
|
||||
|
||||
# Compact in-app guide so the LLM can answer « comment faire… ? » questions even
|
||||
# when no document is attached to the conversation (generic help / onboarding).
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
@@ -151,6 +166,11 @@ class AgentEngine:
|
||||
|
||||
self._persist_message(conversation_id, "user", objective)
|
||||
self._update_conversation(conversation_id, status="running")
|
||||
await _fire_agent_webhook("agent.run.started", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": model or "",
|
||||
})
|
||||
|
||||
# Update the history title right away (before the run finishes) and
|
||||
# refine it once we have the final answer (_autotitle below).
|
||||
@@ -166,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
|
||||
@@ -216,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})
|
||||
@@ -260,19 +305,20 @@ class AgentEngine:
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("agent.run.finished webhook dispatch failed")
|
||||
await _fire_agent_webhook("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
await _fire_agent_webhook("agent.run.failed", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"error": str(exc)[:500],
|
||||
})
|
||||
yield self._event("error", {"message": f"Erreur interne: {exc}"})
|
||||
finally:
|
||||
self._update_conversation(conversation_id, status="idle")
|
||||
|
||||
@@ -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())
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -17,6 +18,8 @@ from fastapi.responses import JSONResponse
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def to_iso8601(value: str | None) -> str | None:
|
||||
@@ -54,7 +57,7 @@ def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("row_to_dict")
|
||||
return d
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────────────────────
|
||||
@@ -163,7 +166,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
||||
if dt.timestamp() < time.time():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("resolve_bearer_token")
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
@@ -175,7 +178,7 @@ def resolve_bearer_token(token: str) -> dict | None:
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("resolve_bearer_token")
|
||||
return d
|
||||
# 2) extension_devices
|
||||
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
||||
@@ -211,9 +214,13 @@ def get_bearer_user(request: Request, authorization: str | None = Header(default
|
||||
return user
|
||||
|
||||
def require_scope(required: str):
|
||||
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
|
||||
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
|
||||
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
user = get_bearer_user(request, authorization)
|
||||
scopes = user.get("_token_scopes") or "read"
|
||||
# Pas de default "read" : identique au contrôle manuel des handlers
|
||||
# (un jeton sans scope est refusé, quel que soit le scope demandé).
|
||||
scopes = user.get("_token_scopes")
|
||||
if not has_scope(scopes, required):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
|
||||
return user
|
||||
@@ -257,7 +264,7 @@ def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("audit_log")
|
||||
|
||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||
|
||||
@@ -307,4 +314,4 @@ def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("store_idempotency")
|
||||
|
||||
+436
-2
@@ -23,7 +23,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -167,6 +168,13 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
url = action.get("url", "").strip()
|
||||
if not url:
|
||||
raise ValueError("webhook action requires a url")
|
||||
# A13 : SSRF — même garde que l'importer URL (loopback/privé refusé).
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
_parsed = _urlparse(url)
|
||||
if _parsed.scheme not in ("http", "https") or not _parsed.hostname or not _is_public_host(_parsed.hostname):
|
||||
raise ValueError(f"webhook url non autorisée: {_parsed.hostname!r}")
|
||||
secret = action.get("secret", "")
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
@@ -246,6 +254,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 +305,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):
|
||||
@@ -305,8 +355,30 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
def run_event_sync(coro, timeout: float = 60.0):
|
||||
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
|
||||
|
||||
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
|
||||
déterministe, exactement ce que faisait l'await avant la conversion des
|
||||
routes en `def`.
|
||||
ponytail: les clients httpx des services sont créés à chaque appel (aucun
|
||||
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
|
||||
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
|
||||
"""
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout))
|
||||
|
||||
|
||||
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 +392,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') ═══════════
|
||||
|
||||
@@ -346,7 +433,7 @@ def cron_due(expression: str, last_run_at: str | None, now: datetime | None = No
|
||||
expr = (expression or "").strip().lower()
|
||||
if not expr:
|
||||
return False
|
||||
now = now or datetime.utcnow()
|
||||
now = now or datetime.now(UTC).replace(tzinfo=None)
|
||||
minute = now.minute
|
||||
fields = expr.split()
|
||||
|
||||
@@ -409,6 +496,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.warning("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)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def backup_db(now: datetime | None = None) -> str | None:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("backup_db")
|
||||
|
||||
dest_dir = _backup_dir()
|
||||
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
||||
|
||||
@@ -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.warning("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}
|
||||
@@ -17,12 +17,12 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
# ═══════════════ Helpers ═══════════════
|
||||
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
|
||||
|
||||
def _data_root() -> Path:
|
||||
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _file_meta(page: dict) -> dict:
|
||||
@@ -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"))
|
||||
|
||||
@@ -35,7 +35,10 @@ class GiteaClient:
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, value: Any) -> None:
|
||||
self._cache[key] = (datetime.now() + self._ttl, value)
|
||||
now = datetime.now()
|
||||
# A42 : évacue les entrées expirées (le dict ne pouvait que grandir)
|
||||
self._cache = {k: v for k, v in self._cache.items() if v[0] > now}
|
||||
self._cache[key] = (now + self._ttl, value)
|
||||
|
||||
# ── repos ──
|
||||
|
||||
@@ -73,6 +76,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)
|
||||
|
||||
@@ -194,7 +194,7 @@ class GitHubAdapter(ForgeAdapter):
|
||||
if langs:
|
||||
repo_info["language"] = max(langs, key=langs.get)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("get_repo_info")
|
||||
|
||||
self._set_cache(cache_key, repo_info)
|
||||
return repo_info
|
||||
|
||||
@@ -98,9 +98,3 @@ def coerce_tags(value: Any) -> list[str]:
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
|
||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"[*_~#>]+", "", text)
|
||||
return text.strip()
|
||||
|
||||
@@ -10,11 +10,11 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.export import markdown_to_blocks
|
||||
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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.config import settings
|
||||
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(settings.data_dir)
|
||||
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"))}
|
||||
@@ -103,6 +103,34 @@ def parse_og(body: str, url: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
async def _get_checked(client, url: str, headers: dict):
|
||||
"""GET avec re-vérification de l'hôte à CHAQUE saut de redirection (A12 SSRF).
|
||||
|
||||
`follow_redirects=True` laisserait une URL publique rediriger vers
|
||||
169.254.169.254 / localhost — la garde doit donc tourner à chaque hop.
|
||||
"""
|
||||
from app.services.importers.url_fetch import _is_public_host
|
||||
|
||||
current = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
parsed = urlparse(current)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise ValueError(f"hôte non autorisé: {parsed.hostname!r}")
|
||||
r = await client.get(current, headers=headers, follow_redirects=False)
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
loc = r.headers.get("location")
|
||||
if not loc:
|
||||
return r
|
||||
current = urljoin(current, loc)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r
|
||||
raise ValueError("trop de redirections")
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
|
||||
"""Fetch ``url`` and return {url, title, description, image, site_name,
|
||||
favicon}. Empty strings are omitted. Never raises for network errors.
|
||||
@@ -121,12 +149,14 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
kwargs = {"follow_redirects": True, "timeout": timeout}
|
||||
kwargs = {"timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
resp = await client.get(src, headers=headers)
|
||||
resp.raise_for_status()
|
||||
resp = await _get_checked(client, src, headers)
|
||||
except ValueError:
|
||||
# A12 : hôte privé/loopback ou trop de redirections → refus explicite.
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
|
||||
logger.debug("og fetch failed for %s: %s", src, exc)
|
||||
base["title"] = urlparse(src).netloc or src
|
||||
|
||||
@@ -98,6 +98,74 @@ class PermissionManager:
|
||||
).fetchone()
|
||||
return "owner" if owner else "viewer"
|
||||
|
||||
# ── SSO (v6.7.0, design §7.2) ─────────────────────────────────────────
|
||||
|
||||
def is_sso_only_workspace(self, workspace_id: int | None = None) -> bool:
|
||||
"""True when that workspace can only be reached through SSO.
|
||||
|
||||
FlowDeck keeps a single instance-wide SSO-only switch (design §7.1 /
|
||||
§4.2): when it is on, local login is refused for every non-admin, so
|
||||
every workspace on the instance is effectively SSO-only.
|
||||
``workspace_id`` is accepted to mirror the design's per-workspace API.
|
||||
"""
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
|
||||
return is_sso_only()
|
||||
|
||||
def _user_auth_method(self) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT auth_method FROM users WHERE id=?", (self.user_id,)
|
||||
).fetchone()
|
||||
return (row["auth_method"] or "local") if row else "local"
|
||||
|
||||
def get_sso_roles(
|
||||
self, user_id: int | None = None, workspace_id: int | None = None
|
||||
) -> list[str]:
|
||||
"""Roles granted to that user through SSO group mapping (design §7.2).
|
||||
|
||||
SSO grants land in the regular ``workspace_members`` row (the mapping
|
||||
is re-applied at every SSO login), so the answer is the explicit
|
||||
membership role of a non-local account — local accounts and users
|
||||
without an explicit grant (the implicit *viewer* fallback is not an
|
||||
SSO grant) get ``[]``.
|
||||
"""
|
||||
if workspace_id is None:
|
||||
return []
|
||||
pm = PermissionManager(int(user_id)) if (user_id and int(user_id) != self.user_id) else self
|
||||
if pm._user_auth_method() == "local":
|
||||
return []
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(int(workspace_id), pm.user_id),
|
||||
).fetchone()
|
||||
return [row["role"]] if row else []
|
||||
|
||||
def sync_sso_permissions(
|
||||
self,
|
||||
user_id: int | None,
|
||||
sso_groups: list[str],
|
||||
workspace_id: int | None = None,
|
||||
) -> list[int]:
|
||||
"""Re-apply the group → workspace role mapping (design §7.2).
|
||||
|
||||
Delegates to ``sso_provisioning.sync_sso_groups`` (the single source
|
||||
of truth used at login and by ``POST /api/v2/sso/sync``). Returns the
|
||||
touched workspace ids, narrowed to ``workspace_id`` when given.
|
||||
"""
|
||||
from app.services.sso_provisioning import get_sso_config, sync_sso_groups
|
||||
|
||||
cfg = get_sso_config()
|
||||
if not cfg:
|
||||
return []
|
||||
touched = sync_sso_groups(int(user_id or self.user_id), list(sso_groups or []), cfg)
|
||||
if workspace_id is not None:
|
||||
touched = [w for w in touched if int(w) == int(workspace_id)]
|
||||
if touched:
|
||||
self.invalidate()
|
||||
return touched
|
||||
|
||||
def can_read(self, workspace_id: int | None) -> bool:
|
||||
return self.role_in_workspace(workspace_id) in READ_ROLES
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -243,13 +247,6 @@ def user_ref(user: dict | None) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
|
||||
"""Compute the value of an auto-property."""
|
||||
if prop_type == "created_time" or prop_type == "last_edited_time":
|
||||
return datetime.now(UTC).isoformat()
|
||||
if prop_type == "created_by" or prop_type == "last_edited_by":
|
||||
return user_ref(user)
|
||||
return None
|
||||
|
||||
|
||||
def apply_auto_properties(
|
||||
@@ -287,28 +284,5 @@ def apply_auto_properties(
|
||||
return values
|
||||
|
||||
|
||||
def get_next_unique_id(collection_id: int, conn) -> int:
|
||||
"""Get the next unique_id for a collection (max + 1)."""
|
||||
row = conn.execute(
|
||||
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
|
||||
FROM collection_pages WHERE collection_id=?""",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else 1
|
||||
|
||||
|
||||
def format_number(value: float, fmt: str = "number") -> str:
|
||||
"""Format a number value for display."""
|
||||
if value is None:
|
||||
return ""
|
||||
if fmt == "percent":
|
||||
return f"{value}%"
|
||||
elif fmt == "dollar":
|
||||
return f"${value:,.2f}"
|
||||
elif fmt == "euro":
|
||||
return f"€{value:,.2f}"
|
||||
elif fmt == "pound":
|
||||
return f"£{value:,.2f}"
|
||||
elif fmt == "yen":
|
||||
return f"¥{value:,.0f}"
|
||||
return str(value)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
|
||||
|
||||
Les trois surfaces divergeaient avant cette passe :
|
||||
|
||||
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
|
||||
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
|
||||
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
|
||||
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
|
||||
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
||||
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
||||
|
||||
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
||||
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
||||
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
||||
donc pas un partage manuel.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
|
||||
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
|
||||
|
||||
|
||||
def _unique_slug(conn, page_id: int, title: str) -> str:
|
||||
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
|
||||
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
|
||||
slug, counter = base, 1
|
||||
while conn.execute(
|
||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||
).fetchone():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
||||
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
return slug, page["title"] or ""
|
||||
|
||||
|
||||
def unpublish(page_id: int) -> None:
|
||||
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def fire_published(page_id: int, slug: str) -> None:
|
||||
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish page.published")
|
||||
|
||||
|
||||
async def fire_unpublished(page_id: int) -> None:
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("publish page.unpublished")
|
||||
@@ -349,7 +349,7 @@ class RealtimeManager:
|
||||
try:
|
||||
await conn.ws.close(code=4413)
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("_evict_slow")
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
||||
|
||||
@@ -196,9 +196,6 @@ def now_in_tz(tz_name: str | None = None) -> dt.datetime:
|
||||
return dt.datetime.now(dt.UTC)
|
||||
|
||||
|
||||
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
|
||||
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
|
||||
return now_in_tz(tz_name).date()
|
||||
|
||||
|
||||
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,231 @@
|
||||
"""FlowDeck — Skill marketplace (v6.6.0, Agent phase 5 « Plateforme »).
|
||||
|
||||
Single source of truth for shareable agent skills:
|
||||
|
||||
* **Gallery** — built-in presets shipped with FlowDeck, installable into any
|
||||
workspace with one call (the self-hosted equivalent of a skill marketplace).
|
||||
* **Portable payloads** — a skill exports to a versioned JSON document that any
|
||||
other FlowDeck instance can re-import unchanged.
|
||||
|
||||
Both the internal router (``/api/agent/skills/*``, session cookie) and the
|
||||
public API v2 (``/api/v2/skills/*``, Bearer + scopes) call into this module so
|
||||
there is exactly one implementation of export/import/install.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
EXPORT_FORMAT = "flowdeck-skill"
|
||||
EXPORT_VERSION = 1
|
||||
|
||||
# ── Gallery presets ────────────────────────────────────────────────────────
|
||||
# `allowed_tools` only references real ToolRegistry names (v4.10 → v5 tool set),
|
||||
# so an installed preset can never expose a tool that does not exist.
|
||||
|
||||
GALLERY: dict[str, dict] = {
|
||||
"rapport-hebdo": {
|
||||
"name": "Rapport hebdo",
|
||||
"icon": "📊",
|
||||
"description": "Agrège les pages modifiées de la semaine et rédige un rapport structuré.",
|
||||
"prompt_template": (
|
||||
"Rédige le rapport hebdomadaire de l'équipe.\n"
|
||||
"1. Repère les pages et documents modifiés cette semaine (search_workspace).\n"
|
||||
"2. Lis les plus significatifs (read_document) et en extrais avancées, blocages, décisions.\n"
|
||||
"3. Crée un document « Rapport hebdo — <date> » (create_document) structuré ainsi : "
|
||||
"Résumé · Faits marquants · Blocages · Plan de la semaine prochaine.\n"
|
||||
"Chaque affirmation doit s'appuyer sur un document lu, jamais sur une supposition."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"compte-rendu-reunion": {
|
||||
"name": "Compte rendu de réunion",
|
||||
"icon": "📝",
|
||||
"description": "Transforme une note brute de réunion en compte rendu avec décisions et tâches.",
|
||||
"prompt_template": (
|
||||
"À partir de la note de réunion fournie (ou demandée) :\n"
|
||||
"1. Crée un document « CR — <titre> » (create_document).\n"
|
||||
"2. Structure : Contexte · Décisions prises · Actions (avec responsable et échéance) · Points ouverts.\n"
|
||||
"3. Ne garde que ce qui est dans la note ; liste explicitement les points manquants."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"base-crm": {
|
||||
"name": "Base CRM",
|
||||
"icon": "🗂️",
|
||||
"description": "Crée une collection CRM (contacts, statut, dernière interaction) avec une vue board.",
|
||||
"prompt_template": (
|
||||
"Crée une base CRM complète :\n"
|
||||
"1. Collection « CRM » (create_collection) avec propriétés : Société (texte), Contact (texte), "
|
||||
"Statut (sélection : Prospect/Négociation/Gagné/Perdu), Montant (nombre), Dernière interaction (date).\n"
|
||||
"2. Vue board groupée sur Statut (create_view).\n"
|
||||
"3. Trois lignes d'exemple réalistes (create_page).\n"
|
||||
"Termine par le lien/identifiant de la collection créée."
|
||||
),
|
||||
"allowed_tools": ["create_collection", "add_property", "create_view", "create_page"],
|
||||
},
|
||||
"okr": {
|
||||
"name": "Objectifs OKR",
|
||||
"icon": "🎯",
|
||||
"description": "Génère une base d'OKR avec objectifs, résultats clés et progression.",
|
||||
"prompt_template": (
|
||||
"Crée une base « OKR » :\n"
|
||||
"1. Collection avec propriétés : Objectif (texte), Responsable (texte), Période (sélection : T1..T4), "
|
||||
"Progression (nombre 0-100).\n"
|
||||
"2. Collection « Résultats clés » liée à l'objectif (add_relation), avec Critère de succès et Progression.\n"
|
||||
"3. Un jeu d'exemple : 3 objectifs, 2 résultats clés chacun (create_page, create_sub_item).\n"
|
||||
"4. Vue board par période (create_view)."
|
||||
),
|
||||
"allowed_tools": [
|
||||
"create_collection", "add_property", "add_relation",
|
||||
"create_view", "create_page", "create_sub_item",
|
||||
],
|
||||
},
|
||||
"analyse-repo": {
|
||||
"name": "Analyse repo Gitea",
|
||||
"icon": "🛠️",
|
||||
"description": "Analyse les issues d'un dépôt Gitea et produit un dashboard de suivi.",
|
||||
"prompt_template": (
|
||||
"Analyse le dépôt Gitea du workspace :\n"
|
||||
"1. Lis les issues ouvertes (read_gitea_issues) et synchronise l'état (sync_gitea).\n"
|
||||
"2. Regroupe par label/priorité : bloquantes, en cours, à trier.\n"
|
||||
"3. Crée un document « Suivi repo — <dépôt> » (create_document) avec un tableau des issues "
|
||||
"et 3 recommandations de priorisation."
|
||||
),
|
||||
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace", "create_document", "write_blocks"],
|
||||
},
|
||||
"resume-document": {
|
||||
"name": "Résumé de document",
|
||||
"icon": "📄",
|
||||
"description": "Résume un document long en une page : points clés, chiffres, décisions.",
|
||||
"prompt_template": (
|
||||
"Résume le document fourni (ou demandé) :\n"
|
||||
"1. Lis-le intégralement (read_document).\n"
|
||||
"2. Crée un document « Résumé — <titre> » (create_document) : 5 points clés, chiffres marquants, "
|
||||
"décisions/engagements, questions restées ouvertes.\n"
|
||||
"3. Maximum une page, phrases courtes, aucune reformulation qui change le sens."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Gallery access ─────────────────────────────────────────────────────────
|
||||
|
||||
def list_gallery() -> list[dict]:
|
||||
"""Return every preset with its slug, ready for API responses."""
|
||||
return [{"slug": slug, **preset} for slug, preset in GALLERY.items()]
|
||||
|
||||
|
||||
def get_gallery(slug: str) -> dict | None:
|
||||
preset = GALLERY.get(str(slug or "").strip().lower())
|
||||
return {"slug": slug, **preset} if preset else None
|
||||
|
||||
|
||||
# ── Portable payload (export / import) ─────────────────────────────────────
|
||||
|
||||
def export_skill(row: Any) -> dict:
|
||||
"""Build the portable JSON document for a stored skill row.
|
||||
|
||||
Only shareable fields are included: ids, workspace and author stay local so
|
||||
an import never leaks (nor depends on) the source instance's internals.
|
||||
"""
|
||||
skill = row if isinstance(row, dict) else dict(row)
|
||||
tools = skill.get("allowed_tools_json") or "[]"
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError):
|
||||
tools = []
|
||||
return {
|
||||
"format": EXPORT_FORMAT,
|
||||
"version": EXPORT_VERSION,
|
||||
"skill": {
|
||||
"name": skill.get("name") or "",
|
||||
"description": skill.get("description") or "",
|
||||
"prompt_template": skill.get("prompt_template") or "",
|
||||
"allowed_tools": list(tools) if isinstance(tools, list) else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_payload(payload: Any) -> dict:
|
||||
"""Validate an import payload → normalized skill fields.
|
||||
|
||||
Accepts a full exported document (``{format, version, skill}``) or a bare
|
||||
skill object (``{name, prompt_template, ...}``) for hand-written imports.
|
||||
Raises ``ValueError`` with a human-readable message on invalid input.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Payload JSON attendu (objet)")
|
||||
skill = payload.get("skill") if isinstance(payload.get("skill"), dict) else payload
|
||||
if payload.get("format") and payload.get("format") != EXPORT_FORMAT:
|
||||
raise ValueError(f"Format inconnu: {payload.get('format')} (attendu {EXPORT_FORMAT})")
|
||||
version = payload.get("version")
|
||||
if version is not None and (not isinstance(version, int) or version > EXPORT_VERSION):
|
||||
raise ValueError(f"Version non supportée: {version} (max {EXPORT_VERSION})")
|
||||
|
||||
name = str(skill.get("name") or "").strip()
|
||||
prompt = str(skill.get("prompt_template") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
if not prompt:
|
||||
raise ValueError("prompt_template est requis")
|
||||
tools = skill.get("allowed_tools") or []
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils") from exc
|
||||
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils")
|
||||
|
||||
return {
|
||||
"name": name[:120],
|
||||
"description": str(skill.get("description") or "")[:500],
|
||||
"prompt_template": prompt,
|
||||
"allowed_tools": tools,
|
||||
}
|
||||
|
||||
|
||||
def upsert_skill(
|
||||
fields: dict,
|
||||
*,
|
||||
workspace_id: int | None,
|
||||
created_by: int | None,
|
||||
overwrite: bool = False,
|
||||
) -> tuple[dict, bool]:
|
||||
"""Insert (or replace) a skill in the current workspace.
|
||||
|
||||
Returns ``(row, created)``. Raises ``ValueError`` when the name already
|
||||
exists and ``overwrite`` is False — callers translate that to HTTP 409.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM agent_skills WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, fields["name"]),
|
||||
).fetchone()
|
||||
if existing and not overwrite:
|
||||
raise ValueError(f"Skill déjà présente dans ce workspace: {fields['name']}")
|
||||
tools_json = json.dumps(fields["allowed_tools"])
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE agent_skills SET description=?, prompt_template=?, allowed_tools_json=? WHERE id=?",
|
||||
(fields["description"], fields["prompt_template"], tools_json, existing["id"]),
|
||||
)
|
||||
skill_id = existing["id"]
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_skills
|
||||
(workspace_id, name, description, prompt_template, allowed_tools_json, created_by)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(workspace_id, fields["name"], fields["description"],
|
||||
fields["prompt_template"], tools_json, created_by),
|
||||
)
|
||||
skill_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
data = dict(row) if row else {"id": skill_id}
|
||||
return data, existing is None
|
||||
@@ -0,0 +1,783 @@
|
||||
"""v6.7.0 — SSO provisioning: config store, auto-provisioning, group mapping.
|
||||
|
||||
Single source of truth for the SSO configuration (``sso_config`` table, with
|
||||
an ``SSO_*`` environment fallback for bootstrap installs) and for what
|
||||
happens when an IdP says "this is [email protected]":
|
||||
|
||||
1. resolve the local account (by email → merge, else by login),
|
||||
2. create it when ``auto_provision`` is on, else reject with an audit row,
|
||||
3. sync attributes + map SSO groups to workspace roles,
|
||||
4. hand back the user dict so the caller can mint a session.
|
||||
|
||||
Secrets at rest: ``client_secret`` and the generated SP private key are
|
||||
encrypted with a Fernet key derived from ``app_secret_key``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_PROVIDER_TYPES = ("saml", "oidc")
|
||||
|
||||
|
||||
class SSOConfigError(Exception):
|
||||
"""Invalid SSO configuration payload (message shown to the admin)."""
|
||||
|
||||
|
||||
class SSOProvisioningError(Exception):
|
||||
"""A login was rejected (no local account, missing attributes…)."""
|
||||
|
||||
|
||||
# ── Secrets at rest ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fernet():
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
|
||||
import base64
|
||||
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return _fernet().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(value.encode()).decode()
|
||||
except Exception:
|
||||
return "" # key rotated / not ours — treat as unset
|
||||
|
||||
|
||||
# ── Config store ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _default_mapping(provider_type: str) -> dict:
|
||||
if provider_type == "oidc":
|
||||
from app.auth.providers.oidc_provider import DEFAULT_OIDC_MAPPING
|
||||
|
||||
return dict(DEFAULT_OIDC_MAPPING)
|
||||
from app.auth.providers.saml_provider import DEFAULT_SAML_MAPPING
|
||||
|
||||
return dict(DEFAULT_SAML_MAPPING)
|
||||
|
||||
|
||||
def _env_config() -> dict | None:
|
||||
"""Bootstrap config from ``SSO_*`` env vars (design doc §3.3).
|
||||
|
||||
Only used when the table holds no active row — the Settings UI always
|
||||
wins once an admin saved a configuration.
|
||||
"""
|
||||
provider_type = (settings.sso_provider or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
return None
|
||||
cfg = {
|
||||
"id": 0,
|
||||
"provider_type": provider_type,
|
||||
"name": settings.sso_name or "Company SSO",
|
||||
"entity_id": settings.sso_entity_id,
|
||||
"sso_url": settings.sso_sso_url,
|
||||
"slo_url": settings.sso_slo_url,
|
||||
"x509_certificate": settings.sso_x509_certificate,
|
||||
"issuer_url": settings.sso_issuer_url,
|
||||
"client_id": settings.sso_client_id,
|
||||
"client_secret": settings.sso_client_secret,
|
||||
"scope": settings.sso_scope,
|
||||
"attribute_mapping": settings.sso_attribute_mapping,
|
||||
"groups_mapping": settings.sso_groups_mapping,
|
||||
"auto_provision": int(settings.sso_auto_provision),
|
||||
"sso_only": int(settings.sso_only),
|
||||
"sign_requests": int(settings.sso_sign_requests),
|
||||
"default_workspace_id": settings.sso_default_workspace_id,
|
||||
"sp_private_key": "",
|
||||
"sp_certificate": "",
|
||||
"workspace_id": None,
|
||||
"active": 1,
|
||||
"_source": "env",
|
||||
}
|
||||
if provider_type == "saml" and (not cfg["entity_id"] or not cfg["sso_url"]):
|
||||
return None
|
||||
if provider_type == "oidc" and (not cfg["issuer_url"] or not cfg["client_id"]):
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def get_sso_config(require_active: bool = True) -> dict | None:
|
||||
"""Active SSO config as a dict (DB row, else env fallback)."""
|
||||
row = _raw_row(require_active=require_active)
|
||||
if row:
|
||||
cfg = dict(row)
|
||||
cfg["_source"] = "db"
|
||||
return cfg
|
||||
if not require_active:
|
||||
return _env_config()
|
||||
return _env_config()
|
||||
|
||||
|
||||
def _raw_row(require_active: bool = True) -> dict | None:
|
||||
"""Raw ``sso_config`` row (``client_secret`` still encrypted, ``_source`` unset)."""
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
where = "WHERE active=1" if require_active else ""
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM sso_config {where} ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
except Exception: # table missing (very old install) → env only
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def client_secret_value(cfg: dict) -> str:
|
||||
"""Plaintext OIDC client secret (decrypted for DB rows, raw for env)."""
|
||||
raw = (cfg or {}).get("client_secret") or ""
|
||||
if not raw:
|
||||
return ""
|
||||
if (cfg or {}).get("_source") == "env":
|
||||
return raw
|
||||
return decrypt_secret(raw)
|
||||
|
||||
|
||||
def _json_field(value, fallback):
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value or "")
|
||||
return parsed if isinstance(parsed, type(fallback)) else fallback
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _strict_json(value, expected, field: str):
|
||||
"""Parse a payload field and reject wrong shapes (before normalize,
|
||||
which would otherwise silently coerce ``"[]"`` → ``{}``)."""
|
||||
if value is None or value == "":
|
||||
return expected()
|
||||
if isinstance(value, (dict, list)):
|
||||
parsed = value
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except Exception as exc:
|
||||
raise SSOConfigError(f"{field} must be valid JSON") from exc
|
||||
if not isinstance(parsed, expected):
|
||||
kind = "object" if expected is dict else "array"
|
||||
raise SSOConfigError(f"{field} must be a JSON {kind}")
|
||||
return parsed
|
||||
|
||||
|
||||
def normalize_config(cfg: dict) -> dict:
|
||||
"""Parse JSON columns + fill defaults (single place for every consumer)."""
|
||||
out = dict(cfg)
|
||||
out["attribute_mapping"] = _json_field(out.get("attribute_mapping"), {})
|
||||
out["groups_mapping"] = _json_field(out.get("groups_mapping"), [])
|
||||
if not out["attribute_mapping"]:
|
||||
out["attribute_mapping"] = _default_mapping(out.get("provider_type", "saml"))
|
||||
for key in ("entity_id", "sso_url", "slo_url", "x509_certificate", "issuer_url",
|
||||
"client_id", "client_secret", "scope", "name"):
|
||||
out[key] = (out.get(key) or "").strip()
|
||||
for key in ("auto_provision", "sso_only", "sign_requests", "active"):
|
||||
out[key] = int(out.get(key) or 0)
|
||||
return out
|
||||
|
||||
|
||||
def validate_config_payload(payload: dict) -> dict:
|
||||
"""Validate + sanitize an admin payload. Raises ``SSOConfigError``."""
|
||||
provider_type = str(payload.get("provider_type") or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
raise SSOConfigError(f"provider_type must be one of {', '.join(VALID_PROVIDER_TYPES)}")
|
||||
|
||||
payload = dict(payload)
|
||||
payload["attribute_mapping"] = _strict_json(
|
||||
payload.get("attribute_mapping"), dict, "attribute_mapping"
|
||||
)
|
||||
payload["groups_mapping"] = _strict_json(
|
||||
payload.get("groups_mapping"), list, "groups_mapping"
|
||||
)
|
||||
cfg = normalize_config({**payload, "provider_type": provider_type})
|
||||
|
||||
if provider_type == "saml":
|
||||
for field in ("entity_id", "sso_url"):
|
||||
if not cfg[field]:
|
||||
raise SSOConfigError(f"SAML requires '{field}'")
|
||||
for field, url in (("sso_url", cfg["sso_url"]), ("slo_url", cfg["slo_url"])):
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise SSOConfigError(f"'{field}' must be an http(s) URL")
|
||||
cert = cfg["x509_certificate"].strip()
|
||||
if cert and "BEGIN CERTIFICATE" not in cert:
|
||||
raise SSOConfigError("x509_certificate must be a PEM certificate")
|
||||
if not cert:
|
||||
raise SSOConfigError("SAML requires the IdP signing certificate (x509_certificate)")
|
||||
cfg["x509_certificate"] = cert
|
||||
else:
|
||||
if not cfg["issuer_url"] or not cfg["client_id"]:
|
||||
raise SSOConfigError("OIDC requires 'issuer_url' and 'client_id'")
|
||||
if not cfg["issuer_url"].startswith(("http://", "https://")):
|
||||
raise SSOConfigError("'issuer_url' must be an http(s) URL")
|
||||
|
||||
if not isinstance(cfg["attribute_mapping"], dict):
|
||||
raise SSOConfigError("attribute_mapping must be a JSON object")
|
||||
if not isinstance(cfg["groups_mapping"], list):
|
||||
raise SSOConfigError("groups_mapping must be a JSON array")
|
||||
for entry in cfg["groups_mapping"]:
|
||||
if not isinstance(entry, dict) or "sso_group" not in entry:
|
||||
raise SSOConfigError("groups_mapping entries need at least an 'sso_group' key")
|
||||
|
||||
ws = cfg.get("default_workspace_id")
|
||||
cfg["default_workspace_id"] = int(ws) if ws not in (None, "", 0) else None
|
||||
return cfg
|
||||
|
||||
|
||||
def save_sso_config(payload: dict, created_by: int | None = None) -> dict:
|
||||
"""Create or replace the single SSO configuration (idempotent)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = validate_config_payload(payload)
|
||||
columns = {
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": json.dumps(cfg["attribute_mapping"]),
|
||||
"groups_mapping": json.dumps(cfg["groups_mapping"]),
|
||||
"auto_provision": cfg["auto_provision"],
|
||||
"sso_only": cfg["sso_only"],
|
||||
"sign_requests": cfg["sign_requests"],
|
||||
"default_workspace_id": cfg["default_workspace_id"],
|
||||
"active": 1,
|
||||
"updated_at": str(int(time.time())),
|
||||
}
|
||||
|
||||
# Secret handling: a blank incoming secret keeps the stored one (the raw
|
||||
# row still holds the Fernet blob — never re-encrypt a decrypted value).
|
||||
existing = _raw_row() or {}
|
||||
if "client_secret" in cfg:
|
||||
incoming = str(cfg.get("client_secret") or "").strip()
|
||||
if incoming:
|
||||
columns["client_secret"] = encrypt_secret(incoming)
|
||||
else:
|
||||
columns["client_secret"] = existing.get("client_secret") or ""
|
||||
# SP keypair: keep an existing one, generate one for SAML if missing.
|
||||
sp_key = existing.get("sp_private_key") or ""
|
||||
sp_cert = existing.get("sp_certificate") or ""
|
||||
if cfg["provider_type"] == "saml" and not (sp_key and sp_cert):
|
||||
sp_key, sp_cert = generate_sp_keypair()
|
||||
columns["sp_private_key"] = sp_key
|
||||
columns["sp_certificate"] = sp_cert
|
||||
if created_by:
|
||||
columns["created_by"] = created_by
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM sso_config ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
sets = ", ".join(f"{k}=?" for k in columns)
|
||||
conn.execute(f"UPDATE sso_config SET {sets} WHERE id=?", (*columns.values(), row["id"]))
|
||||
cfg_id = row["id"]
|
||||
else:
|
||||
keys = ", ".join(columns)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO sso_config ({keys}) VALUES ({placeholders})", tuple(columns.values())
|
||||
)
|
||||
cfg_id = cur.lastrowid
|
||||
conn.commit()
|
||||
saved = get_sso_config(require_active=False)
|
||||
saved["id"] = cfg_id
|
||||
return saved
|
||||
|
||||
|
||||
def delete_sso_config() -> bool:
|
||||
"""Disable SSO entirely (local logins keep working)."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE sso_config SET active=0, updated_at=?", (str(int(time.time())),))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def public_config_view(cfg: dict | None) -> dict:
|
||||
"""Config for the admin UI — secrets never leave the server."""
|
||||
if not cfg:
|
||||
return {"configured": False}
|
||||
cfg = normalize_config(cfg)
|
||||
return {
|
||||
"configured": True,
|
||||
"id": cfg.get("id"),
|
||||
"source": cfg.get("_source", "db"),
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"client_secret_set": bool(client_secret_value(cfg)),
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": cfg["attribute_mapping"],
|
||||
"groups_mapping": cfg["groups_mapping"],
|
||||
"auto_provision": bool(cfg["auto_provision"]),
|
||||
"sso_only": bool(cfg["sso_only"]),
|
||||
"sign_requests": bool(cfg["sign_requests"]),
|
||||
"default_workspace_id": cfg.get("default_workspace_id"),
|
||||
"sp_certificate": cfg.get("sp_certificate") or "",
|
||||
"active": bool(cfg.get("active", 1)),
|
||||
"provisioned_users": provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
def is_sso_only(cfg: dict | None = None) -> bool:
|
||||
"""True when local login must be refused (design §7.1 / §4.2)."""
|
||||
cfg = cfg if cfg is not None else get_sso_config()
|
||||
return bool(cfg and normalize_config(cfg).get("sso_only"))
|
||||
|
||||
|
||||
def provisioned_count() -> int:
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchone()
|
||||
return int(row["n"] if row is not None else 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def generate_sp_keypair() -> tuple[str, str]:
|
||||
"""RSA-2048 key + self-signed certificate for the SP (metadata + signing)."""
|
||||
import datetime
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"flowdeck-sp-{secrets.token_hex(4)}"),
|
||||
])
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
priv = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
public = cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
return priv, public
|
||||
|
||||
|
||||
def ensure_sp_keypair(cfg: dict) -> dict:
|
||||
"""Guarantee the SAML config carries an SP keypair (generates + persists)."""
|
||||
if cfg.get("provider_type") != "saml":
|
||||
return cfg
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
return cfg
|
||||
from app.db import get_conn
|
||||
|
||||
priv, cert = generate_sp_keypair()
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sso_config SET sp_private_key=?, sp_certificate=? WHERE id=?",
|
||||
(priv, cert, cfg.get("id")),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # env-sourced config has no row to update
|
||||
logger.debug("SP keypair not persisted: %s", err)
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
|
||||
|
||||
# ── Audit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def log_sso_login(
|
||||
*,
|
||||
user_id: int | None,
|
||||
provider_type: str,
|
||||
provider_name: str,
|
||||
identifier: str,
|
||||
request,
|
||||
success: bool,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Write one ``sso_login_history`` row (failures included — design §5.2)."""
|
||||
ip = request.client.host if request is not None and getattr(request, "client", None) else ""
|
||||
ua = (request.headers.get("user-agent", "") if request is not None else "")[:500]
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO sso_login_history
|
||||
(user_id, provider_type, provider_name, sso_identifier,
|
||||
ip_address, user_agent, success, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, provider_type, provider_name, (identifier or "")[:320], ip, ua,
|
||||
1 if success else 0, (error or "")[:500]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # audit must never break the login path
|
||||
logger.warning("sso_login_history write failed: %s", err)
|
||||
|
||||
|
||||
# ── Group mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sync_sso_groups(user_id: int, sso_groups: list[str], cfg: dict) -> list[int]:
|
||||
"""Apply ``groups_mapping`` → ``workspace_members.role``. Returns touched ws ids."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
mappings = cfg.get("groups_mapping") or []
|
||||
wanted = {g.strip().lower() for g in sso_groups if g and str(g).strip()}
|
||||
touched: list[int] = []
|
||||
|
||||
with get_conn() as conn:
|
||||
for entry in mappings:
|
||||
group_name = str(entry.get("sso_group") or "").strip().lower()
|
||||
if not group_name or group_name not in wanted:
|
||||
continue
|
||||
ws_id = entry.get("workspace_id") or cfg.get("default_workspace_id")
|
||||
if not ws_id:
|
||||
continue
|
||||
role = str(entry.get("workspace_role") or "editor").strip() or "editor"
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
role = "editor"
|
||||
conn.execute(
|
||||
"""INSERT INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(workspace_id, user_id) DO UPDATE SET role=excluded.role""",
|
||||
(int(ws_id), user_id, role),
|
||||
)
|
||||
touched.append(int(ws_id))
|
||||
|
||||
# Default workspace: every SSO user lands there as a plain member.
|
||||
default_ws = cfg.get("default_workspace_id")
|
||||
if default_ws:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, 'editor')""",
|
||||
(int(default_ws), user_id),
|
||||
)
|
||||
if int(default_ws) not in touched:
|
||||
touched.append(int(default_ws))
|
||||
conn.commit()
|
||||
return touched
|
||||
|
||||
|
||||
def force_sync_all_groups() -> dict:
|
||||
"""Re-apply the group mapping for every SSO user (``POST /api/v2/sso/sync``)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = get_sso_config()
|
||||
if not cfg:
|
||||
raise SSOProvisioningError("No SSO configuration")
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
groups_source = mapping.get("groups", "groups")
|
||||
updated = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, auth_method FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
groups = _stored_groups(row["id"], groups_source, cfg)
|
||||
if sync_sso_groups(row["id"], groups, cfg):
|
||||
updated += 1
|
||||
return {"users": len(rows), "updated": updated}
|
||||
|
||||
|
||||
def _stored_groups(user_id: int, source: str, cfg: dict) -> list[str]:
|
||||
"""Groups seen at the last login of that user (stored in attribute sync)."""
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sso_identifier FROM sso_login_history "
|
||||
"WHERE user_id=? AND success=1 ORDER BY id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if not row or not row["sso_identifier"]:
|
||||
return []
|
||||
raw = row["sso_identifier"]
|
||||
if "|" in raw:
|
||||
ident, _, groups_json = raw.partition("|")
|
||||
groups = json.loads(groups_json or "[]")
|
||||
return [str(g) for g in groups] if isinstance(groups, list) else []
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── Auto-provisioning ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _unique_login(conn, base: str) -> str:
|
||||
candidate = base
|
||||
n = 1
|
||||
while conn.execute("SELECT 1 FROM users WHERE login=?", (candidate,)).fetchone():
|
||||
n += 1
|
||||
candidate = f"{base}_{n}"
|
||||
return candidate
|
||||
|
||||
|
||||
def identity_from_saml(identity, cfg: dict) -> dict:
|
||||
"""Apply the SAML attribute mapping to a validated assertion."""
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
out = {
|
||||
"login": identity.resolve(mapping.get("login", "nameid")),
|
||||
"email": identity.resolve(mapping.get("email", "nameid")),
|
||||
"full_name": identity.resolve(mapping.get("full_name", "displayName")),
|
||||
"avatar_url": identity.resolve(mapping.get("avatar_url", "avatar")),
|
||||
"name_id": identity.name_id,
|
||||
}
|
||||
groups = identity.resolve(mapping.get("groups", "groups"))
|
||||
if groups:
|
||||
# Multi-valued SAML attribute: take every value of the resolved source.
|
||||
source = mapping.get("groups", "groups")
|
||||
values = identity.attributes.get(source) or identity.friendly_attributes.get(source) or [groups]
|
||||
out["groups"] = [str(v).strip() for v in values if v and str(v).strip()]
|
||||
else:
|
||||
out["groups"] = []
|
||||
out["email"] = (out["email"] or "").strip().lower()
|
||||
if "@" not in out["email"]:
|
||||
# NameID may be a persistent opaque id — fall back to login when it is
|
||||
# an email, otherwise leave empty (login will carry the identity).
|
||||
out["email"] = out["email"] if "@" in (out["login"] or "") else ""
|
||||
if not out["full_name"]:
|
||||
out["full_name"] = out["email"] or out["login"]
|
||||
out["login"] = out["login"] or out["email"] or f"sso_{identity.name_id[:32]}"
|
||||
return out
|
||||
|
||||
|
||||
def handle_sso_login(identity: dict, *, provider_type: str, cfg: dict, request) -> dict:
|
||||
"""Resolve/create the local user for an SSO identity. Returns the user dict.
|
||||
|
||||
Raises ``SSOProvisioningError`` when the login must be refused (the
|
||||
caller writes the audit row).
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
email = (identity.get("email") or "").strip().lower()
|
||||
login_hint = (identity.get("login") or "").strip()
|
||||
if not email and not login_hint:
|
||||
raise SSOProvisioningError(
|
||||
"SSO assertion carries no usable email/login — check the attribute mapping"
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
user = None
|
||||
if email:
|
||||
user = conn.execute(
|
||||
"SELECT * FROM users WHERE lower(email)=? AND email!='' ORDER BY id LIMIT 1",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if not user and login_hint:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login_hint,)).fetchone()
|
||||
|
||||
if user:
|
||||
# §7.1 — email match → merge: the existing account is reused and
|
||||
# tagged with the SSO method (no duplicate account).
|
||||
updates, params = [], []
|
||||
if identity.get("full_name"):
|
||||
updates.append("full_name=?")
|
||||
params.append(identity["full_name"])
|
||||
if email:
|
||||
updates.append("email=?")
|
||||
params.append(email)
|
||||
if identity.get("avatar_url"):
|
||||
updates.append("avatar_url=?")
|
||||
params.append(identity["avatar_url"])
|
||||
updates.append("auth_method=?")
|
||||
params.append(provider_type)
|
||||
updates.append("last_login=?")
|
||||
params.append(str(time.time()))
|
||||
params.append(user["id"])
|
||||
conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
else:
|
||||
if not cfg.get("auto_provision"):
|
||||
raise SSOProvisioningError(
|
||||
"No local account for this SSO identity and auto-provisioning is disabled"
|
||||
)
|
||||
base_login = login_hint or email
|
||||
login = _unique_login(conn, base_login)
|
||||
conn.execute(
|
||||
"""INSERT INTO users
|
||||
(login, full_name, email, avatar_url, auth_method, is_admin, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)""",
|
||||
(
|
||||
login,
|
||||
identity.get("full_name") or email or login,
|
||||
email,
|
||||
identity.get("avatar_url") or "",
|
||||
provider_type,
|
||||
str(time.time()),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise SSOProvisioningError("Could not create or load the SSO user")
|
||||
user_dict = dict(row)
|
||||
sync_sso_groups(user_dict["id"], identity.get("groups") or [], cfg)
|
||||
return user_dict
|
||||
|
||||
|
||||
def sso_identifier_field(identity: dict) -> str:
|
||||
"""Audit identifier: ``nameid|["groups",...]`` (groups kept for re-sync)."""
|
||||
ident = identity.get("name_id") or identity.get("email") or identity.get("login") or ""
|
||||
groups = identity.get("groups") or []
|
||||
if groups:
|
||||
return f"{ident}|{json.dumps(groups)}"
|
||||
return ident
|
||||
|
||||
|
||||
def safe_next_path(candidate: str | None) -> str:
|
||||
"""Sanitize the post-login redirect target (open-redirect guard)."""
|
||||
if not candidate:
|
||||
return "/workspaces"
|
||||
candidate = str(candidate)
|
||||
if not candidate.startswith("/") or candidate.startswith("//"):
|
||||
return "/workspaces"
|
||||
parsed = urllib.parse.urlsplit(candidate)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return "/workspaces"
|
||||
return candidate
|
||||
|
||||
|
||||
# ── Anti-replay request store ──────────────────────────────────────────────
|
||||
|
||||
REQUEST_TTL_SECONDS = 600 # AuthnRequest / OIDC state lifetime
|
||||
|
||||
|
||||
def create_request(kind: str, *, request_id: str, relay_state: str = "",
|
||||
code_verifier: str = "", next_path: str = "/workspaces") -> None:
|
||||
"""Store a single-use SSO request (AuthnRequest id / OIDC state)."""
|
||||
from app.db import get_conn
|
||||
|
||||
purge_stale_requests()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO sso_requests
|
||||
(id, kind, relay_state, code_verifier, next_path, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)""",
|
||||
(request_id, kind, relay_state, code_verifier, safe_next_path(next_path)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def consume_request(kind: str, request_id: str, relay_state: str = "") -> dict | None:
|
||||
"""Atomically consume a request. Returns the row, or None (replay/unknown)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if relay_state and row["relay_state"] and not secrets.compare_digest(
|
||||
row["relay_state"], relay_state
|
||||
):
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"UPDATE sso_requests SET used=1 WHERE id=? AND used=0", (request_id,)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount != 1:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def peek_request(kind: str, request_id: str) -> dict | None:
|
||||
"""Read a request without consuming it (CSRF check before heavy validation)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def was_consumed(kind: str, request_id: str) -> bool:
|
||||
"""True when this single-use request id was already spent (replay)."""
|
||||
if not request_id:
|
||||
return False
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sso_requests WHERE id=? AND kind=? AND used=1",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def purge_stale_requests() -> None:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("purge_stale_requests")
|
||||
@@ -306,14 +306,3 @@ def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
|
||||
|
||||
# ── Unsync: convert synced block to independent copy ──────────────
|
||||
|
||||
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
|
||||
"""Remove a page's sync reference and return the current content
|
||||
so the caller can turn it into an independent block."""
|
||||
sb = get_synced_block(synced_block_id)
|
||||
if not sb:
|
||||
return None
|
||||
remove_page_synced(page_id, synced_block_id)
|
||||
try:
|
||||
return json.loads(sb["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -17,6 +17,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
@@ -744,7 +745,7 @@ class DeleteDocument(Tool):
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Document #{pid} introuvable")
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
|
||||
(datetime.utcnow().isoformat(), pid))
|
||||
(datetime.now(UTC).replace(tzinfo=None).isoformat(), pid))
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="document", target_id=pid,
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -46,7 +46,7 @@ def purge_expired(days: int = 30) -> dict:
|
||||
|
||||
Returns a summary of what was purged.
|
||||
"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=days)
|
||||
purged: list[int] = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -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
|
||||
@@ -384,19 +384,6 @@ def register_device(user_id: int, device_id: str, device_name: str = "", extensi
|
||||
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
|
||||
|
||||
|
||||
def verify_device_token(device_id: str, token: str) -> dict | None:
|
||||
"""Verify a device token, returns device row or None."""
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
|
||||
(device_id, thash),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
|
||||
|
||||
@@ -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}
|
||||
@@ -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 ""
|
||||
@@ -83,8 +83,3 @@ def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def find_referring(content: str, page_id: int) -> bool:
|
||||
"""True when the content references ``page_id`` (anchor or wiki token)."""
|
||||
if not content:
|
||||
return False
|
||||
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
|
||||
|
||||
@@ -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}
|
||||
@@ -15,172 +15,7 @@
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
||||
inclue ce partial dans base.html ou directement, le js ne s'exécute
|
||||
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
|
||||
fdCtx.openMenu(evt, node, pageHash, handlers)
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
if (window.__fdCtxMenuRegistered) return;
|
||||
window.__fdCtxMenuRegistered = true;
|
||||
|
||||
// Sur un chargement complet de page, ce script inline s'exécute AVANT
|
||||
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
|
||||
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
|
||||
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
|
||||
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
|
||||
function registerFdCtx() {
|
||||
if (!window.Alpine) return;
|
||||
if (window.Alpine.__fdCtxStore) return;
|
||||
window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
/* Tags (workspace) */
|
||||
availTags: [], tagAdding: false, tagExistingOpen: false,
|
||||
newTagColor: '#787774',
|
||||
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||
/* Icon picker */
|
||||
iconOpen: false,
|
||||
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
|
||||
|
||||
/* Positionne le menu à l'écran et expose les handlers de la page.
|
||||
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
|
||||
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
|
||||
openMenu(ev, node, pageHash, handlers) {
|
||||
handlers = handlers || {};
|
||||
this.node = node;
|
||||
this.page = pageHash;
|
||||
this.handlers = handlers;
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
||||
var cx = (ev && ev.clientX) || 0;
|
||||
var cy = (ev && ev.clientY) || 0;
|
||||
this._cx = cx;
|
||||
this._cy = cy;
|
||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
||||
this.open = true;
|
||||
var self = this;
|
||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
|
||||
Un ResizeObserver relance le placement à chaque fois que le menu
|
||||
change de taille (ouverture d'un sous-menu « tag », icônes, …),
|
||||
sinon il grandissait vers le bas et sortait de l'écran. */
|
||||
var after = function () {
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
};
|
||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
|
||||
else setTimeout(after, 0);
|
||||
},
|
||||
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
|
||||
disponible sous son ancre (le contenu déborde en scroll interne). */
|
||||
_place() {
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (!el) return;
|
||||
var prev = el.style.maxHeight;
|
||||
el.style.maxHeight = 'none';
|
||||
var w = el.offsetWidth || 240;
|
||||
var h = el.offsetHeight || 320;
|
||||
el.style.maxHeight = prev || '';
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var cx = (this._cx == null ? this.x : this._cx);
|
||||
var cy = (this._cy == null ? this.y : this._cy);
|
||||
var nx = cx;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
nx = Math.max(8, nx);
|
||||
var ny = cy;
|
||||
if (ny + h > vh - 8) {
|
||||
if (cy - h >= 8) ny = cy - h;
|
||||
else ny = Math.max(8, vh - h - 8);
|
||||
}
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
},
|
||||
|
||||
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
|
||||
has(key) { return typeof this.handlers[key] === 'function'; },
|
||||
|
||||
/* Exécute l'action → handler fourni par la page courante. */
|
||||
run(key) {
|
||||
if (!this.node) { this.close(); return; }
|
||||
var fn = this.handlers[key];
|
||||
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
|
||||
this.close();
|
||||
},
|
||||
|
||||
/* ── Tags ── */
|
||||
avail() { return this.availTags || []; },
|
||||
setAvail(a) { this.availTags = a || []; },
|
||||
removeTag(id) {
|
||||
var fn = this.handlers.tagRemove;
|
||||
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
|
||||
},
|
||||
addExistingTag(t) {
|
||||
var fn = this.handlers.tagExisting;
|
||||
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
|
||||
this.tagExistingOpen = false;
|
||||
},
|
||||
addNewTag(name, color) {
|
||||
name = (name || '').trim();
|
||||
if (!name) return;
|
||||
var fn = this.handlers.tagAdd;
|
||||
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
|
||||
this.tagAdding = false;
|
||||
},
|
||||
|
||||
/* ── Icon picker ── */
|
||||
setIcon(icon) {
|
||||
icon = (icon || '').trim();
|
||||
var fn = this.handlers.setIcon;
|
||||
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
|
||||
this.close();
|
||||
},
|
||||
openIconPicker() {
|
||||
var fn = this.handlers.setIcon;
|
||||
if (!fn) return;
|
||||
if (!window.FDIconPicker) return;
|
||||
window.FDIconPicker.openFor({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
onPick: fn,
|
||||
onRemove: function () { fn(''); }
|
||||
});
|
||||
this.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (window.Alpine) {
|
||||
registerFdCtx();
|
||||
} else {
|
||||
document.addEventListener('alpine:init', registerFdCtx);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_ctx_menu.js?v={{ asset_version }}"></script>
|
||||
|
||||
<style>
|
||||
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||
<script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||
|
||||
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
@@ -142,7 +142,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||
|
||||
@@ -10,306 +10,9 @@
|
||||
############################################################################}
|
||||
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
|
||||
|
||||
<script data-cfasync="false">
|
||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
||||
(function () {
|
||||
var FD_ICONS = {
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
|
||||
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
|
||||
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
|
||||
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
||||
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
||||
};
|
||||
window.FD_ICONS = FD_ICONS;
|
||||
window.fd_icon = function (name, size) {
|
||||
size = size || 18;
|
||||
var inner = FD_ICONS[name];
|
||||
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
|
||||
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
|
||||
};
|
||||
/* Render a page_icon value: image URL, known icon name, or emoji text. */
|
||||
window.fdIconHtml = function (value, size) {
|
||||
size = size || 16;
|
||||
var v = (value == null ? '' : String(value)).trim();
|
||||
if (!v) return '';
|
||||
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
|
||||
return '<img src="' + v.replace(/"/g, '"') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
|
||||
}
|
||||
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
|
||||
return v;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_1.js?v={{ asset_version }}"></script>
|
||||
|
||||
<script data-cfasync="false">
|
||||
(function () {
|
||||
if (window.__fdIconPickerRegistered) return;
|
||||
window.__fdIconPickerRegistered = true;
|
||||
|
||||
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
var TONEABLE = {};
|
||||
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
|
||||
|
||||
var CATS = [
|
||||
{ id: 'people', label: 'People', items: [
|
||||
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
|
||||
]},
|
||||
{ id: 'nature', label: 'Nature', items: [
|
||||
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
|
||||
]},
|
||||
{ id: 'food', label: 'Food', items: [
|
||||
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
|
||||
]},
|
||||
{ id: 'activity', label: 'Activity', items: [
|
||||
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
|
||||
]},
|
||||
{ id: 'travel', label: 'Travel', items: [
|
||||
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
|
||||
]},
|
||||
{ id: 'objects', label: 'Objects', items: [
|
||||
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
|
||||
]},
|
||||
{ id: 'symbols', label: 'Symbols', items: [
|
||||
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
|
||||
]},
|
||||
{ id: 'flags', label: 'Flags', items: [
|
||||
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️🌈','rainbow flag pride'],['🏴☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
|
||||
]}
|
||||
];
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdIconPicker) return;
|
||||
if (window.Alpine) window.Alpine.__fdIconPicker = true;
|
||||
|
||||
Alpine.store('fdIconPicker', {
|
||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
|
||||
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
|
||||
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
|
||||
onPick: null, onRemove: null, _cx: 0, _cy: 0,
|
||||
cats: CATS,
|
||||
|
||||
openFor(opts) {
|
||||
opts = opts || {};
|
||||
this.onPick = opts.onPick || null;
|
||||
this.onRemove = opts.onRemove || null;
|
||||
this.query = '';
|
||||
this.toneOpen = false;
|
||||
this.customModal = false;
|
||||
this._cx = (opts.x != null) ? opts.x : 240;
|
||||
this._cy = (opts.y != null) ? opts.y : 200;
|
||||
this.x = this._cx;
|
||||
this.y = this._cy;
|
||||
this.open = true;
|
||||
var self = this;
|
||||
var place = function () {
|
||||
var el = document.querySelector('.fd-icon-picker');
|
||||
if (!el) return;
|
||||
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var nx = self._cx, ny = self._cy;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
if (ny + h > vh - 8) ny = vh - h - 8;
|
||||
self.x = Math.max(8, nx);
|
||||
self.y = Math.max(8, ny);
|
||||
};
|
||||
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
|
||||
else setTimeout(place, 0);
|
||||
this.loadRecent();
|
||||
this.loadCustom();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.customModal = false;
|
||||
this.toneOpen = false;
|
||||
this.onPick = null;
|
||||
this.onRemove = null;
|
||||
},
|
||||
|
||||
matches(name) {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return name.toLowerCase().indexOf(q) >= 0;
|
||||
},
|
||||
|
||||
items() {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (q) {
|
||||
var out = [];
|
||||
this.cats.forEach(function (c) {
|
||||
c.items.forEach(function (it) {
|
||||
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (this.category === 'recent') return this.recent;
|
||||
var found = [];
|
||||
for (var i = 0; i < this.cats.length; i++) {
|
||||
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
|
||||
}
|
||||
return found;
|
||||
},
|
||||
|
||||
withTone(e) {
|
||||
if (!this.skinTone) return e;
|
||||
if (TONEABLE[e]) return e + TONES[this.skinTone];
|
||||
return e;
|
||||
},
|
||||
|
||||
pick(v) {
|
||||
v = (v || '').trim();
|
||||
if (!v) return;
|
||||
this.pushRecent(v);
|
||||
var fn = this.onPick;
|
||||
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
|
||||
this.close();
|
||||
},
|
||||
|
||||
remove() {
|
||||
var fn = this.onRemove || this.onPick;
|
||||
if (fn) { try { fn(''); } catch (e) {} }
|
||||
this.close();
|
||||
},
|
||||
|
||||
random() {
|
||||
var pool = [];
|
||||
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
|
||||
if (!pool.length) return;
|
||||
this.pick(pool[Math.floor(Math.random() * pool.length)]);
|
||||
},
|
||||
|
||||
setTone(i) { this.skinTone = i; this.toneOpen = false; },
|
||||
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
|
||||
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
|
||||
|
||||
loadRecent() {
|
||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
||||
catch (e) { this.recent = []; }
|
||||
},
|
||||
pushRecent(e) {
|
||||
try {
|
||||
var r = this.recent.filter(function (x) { return x !== e; });
|
||||
r.unshift(e);
|
||||
this.recent = r.slice(0, 32);
|
||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
||||
} catch (err) {}
|
||||
},
|
||||
|
||||
async loadCustom() {
|
||||
try {
|
||||
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
this.custom = (d && d.emojis) || [];
|
||||
this.customLoaded = true;
|
||||
} catch (e) { this.custom = []; }
|
||||
},
|
||||
|
||||
openCustomModal() {
|
||||
this.customModal = true;
|
||||
this.customName = '';
|
||||
this.customPreview = '';
|
||||
this.customFile = null;
|
||||
this.tab = 'upload';
|
||||
},
|
||||
closeCustomModal() { this.customModal = false; },
|
||||
onCustomFile(ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
this.customFile = f;
|
||||
var self = this;
|
||||
var fr = new FileReader();
|
||||
fr.onload = function () { self.customPreview = fr.result; };
|
||||
fr.readAsDataURL(f);
|
||||
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
|
||||
},
|
||||
async saveCustom() {
|
||||
if (!this.customFile || this.customBusy) return;
|
||||
this.customBusy = true;
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('name', this.customName || 'emoji');
|
||||
fd.append('file', this.customFile);
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
if (d && d.emoji) {
|
||||
this.custom.unshift(d.emoji);
|
||||
this.customModal = false;
|
||||
this.pick(d.emoji.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
||||
}
|
||||
this.customBusy = false;
|
||||
},
|
||||
async deleteCustom(id) {
|
||||
try {
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.FDIconPicker = {
|
||||
openFor: function (opts) {
|
||||
var s = window.Alpine && Alpine.store('fdIconPicker');
|
||||
if (s) s.openFor(opts);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_2.js?v={{ asset_version }}"></script>
|
||||
|
||||
<style>
|
||||
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
||||
|
||||
@@ -9,535 +9,5 @@
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||
// carry legacy id-less blocks; without this they collide on
|
||||
// data-bid="undefined" and the merge below duplicated every line.
|
||||
blocks = (blocks || []).slice();
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
if (!blocks.length) {
|
||||
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||
out = (E.blocks || []).slice();
|
||||
} else {
|
||||
out = blocks.map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) { return; }
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'synced_update') {
|
||||
// A synced block was updated — re-sync the whole page
|
||||
if (m.synced_id) {
|
||||
needSync = true;
|
||||
send({ t: 'sync_req' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
</script>
|
||||
<script type="application/json" id="rt-config" nonce="{{ csp_nonce() }}">{{ {"id": user.get("id") if user else 0, "login": user.get("login", "") if user else "", "full_name": user.get("full_name", "") if user else "", "color": (user.get("avatar_color", "") or "") if user else ""} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/page_editor_realtime.js?v={{ asset_version }}"></script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user